diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java
index 1f0ec329f6..f9ab6650d7 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java
@@ -53,6 +53,7 @@ import com.linkedin.datahub.graphql.resolvers.businessattribute.UpdateBusinessAt
import com.linkedin.datahub.graphql.resolvers.chart.BrowseV2Resolver;
import com.linkedin.datahub.graphql.resolvers.chart.ChartStatsSummaryResolver;
import com.linkedin.datahub.graphql.resolvers.config.AppConfigResolver;
+import com.linkedin.datahub.graphql.resolvers.config.ProductUpdateResolver;
import com.linkedin.datahub.graphql.resolvers.connection.UpsertConnectionResolver;
import com.linkedin.datahub.graphql.resolvers.container.ContainerEntitiesResolver;
import com.linkedin.datahub.graphql.resolvers.container.ParentContainersResolver;
@@ -340,6 +341,7 @@ import com.linkedin.metadata.service.LineageService;
import com.linkedin.metadata.service.OwnershipTypeService;
import com.linkedin.metadata.service.PageModuleService;
import com.linkedin.metadata.service.PageTemplateService;
+import com.linkedin.metadata.service.ProductUpdateService;
import com.linkedin.metadata.service.QueryService;
import com.linkedin.metadata.service.SettingsService;
import com.linkedin.metadata.service.ViewService;
@@ -405,6 +407,7 @@ public class GmsGraphQLEngine {
private final InviteTokenService inviteTokenService;
private final PostService postService;
private final SettingsService settingsService;
+ private final ProductUpdateService productUpdateService;
private final ViewService viewService;
private final OwnershipTypeService ownershipTypeService;
private final LineageService lineageService;
@@ -547,6 +550,10 @@ public class GmsGraphQLEngine {
this.viewService = args.viewService;
this.ownershipTypeService = args.ownershipTypeService;
this.settingsService = args.settingsService;
+ this.productUpdateService =
+ new ProductUpdateService(
+ args.featureFlags.getProductUpdatesJsonUrl(),
+ args.featureFlags.getProductUpdatesJsonFallbackResourceUrl());
this.lineageService = args.lineageService;
this.queryService = args.queryService;
this.erModelRelationshipService = args.erModelRelationshipService;
@@ -988,6 +995,9 @@ public class GmsGraphQLEngine {
this.featureFlags,
this.chromeExtensionConfiguration,
this.settingsService))
+ .dataFetcher(
+ "latestProductUpdate",
+ new ProductUpdateResolver(this.productUpdateService, this.featureFlags))
.dataFetcher("me", new MeResolver(this.entityClient, featureFlags))
.dataFetcher("search", new SearchResolver(this.entityClient))
.dataFetcher(
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java
new file mode 100644
index 0000000000..3c36504400
--- /dev/null
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java
@@ -0,0 +1,72 @@
+package com.linkedin.datahub.graphql.resolvers.config;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.linkedin.datahub.graphql.generated.ProductUpdate;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Utility for parsing product update JSON into GraphQL ProductUpdate objects.
+ *
+ *
Handles validation, field extraction, and error cases for product update data.
+ */
+@Slf4j
+public class ProductUpdateParser {
+
+ private ProductUpdateParser() {
+ // Utility class, no instantiation
+ }
+
+ /**
+ * Parse JSON into a ProductUpdate object.
+ *
+ * @param jsonOpt Optional JSON node containing product update data
+ * @return ProductUpdate object if parsing succeeds and update is enabled, null otherwise
+ */
+ @Nullable
+ public static ProductUpdate parseProductUpdate(@Nonnull Optional jsonOpt) {
+ if (jsonOpt.isEmpty()) {
+ log.debug("No product update JSON available");
+ return null;
+ }
+
+ JsonNode json = jsonOpt.get();
+
+ // Parse and validate required fields
+ if (!json.has("enabled") || !json.has("id") || !json.has("title")) {
+ log.warn("Product update JSON missing required fields (enabled, id, or title)");
+ return null;
+ }
+
+ boolean enabled = json.get("enabled").asBoolean();
+ if (!enabled) {
+ log.debug("Product update is disabled in JSON");
+ return null;
+ }
+
+ String id = json.get("id").asText();
+ String title = json.get("title").asText();
+ String ctaText = json.has("ctaText") ? json.get("ctaText").asText() : "Learn more";
+ String ctaLink = json.has("ctaLink") ? json.get("ctaLink").asText() : "";
+
+ // Build the ProductUpdate response
+ ProductUpdate productUpdate = new ProductUpdate();
+ productUpdate.setEnabled(enabled);
+ productUpdate.setId(id);
+ productUpdate.setTitle(title);
+ productUpdate.setCtaText(ctaText);
+ productUpdate.setCtaLink(ctaLink);
+
+ // Optional fields
+ if (json.has("description")) {
+ productUpdate.setDescription(json.get("description").asText());
+ }
+ if (json.has("image")) {
+ productUpdate.setImage(json.get("image").asText());
+ }
+
+ return productUpdate;
+ }
+}
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java
new file mode 100644
index 0000000000..dbe2b545a2
--- /dev/null
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java
@@ -0,0 +1,75 @@
+package com.linkedin.datahub.graphql.resolvers.config;
+
+import com.linkedin.datahub.graphql.featureflags.FeatureFlags;
+import com.linkedin.datahub.graphql.generated.ProductUpdate;
+import com.linkedin.metadata.service.ProductUpdateService;
+import graphql.schema.DataFetcher;
+import graphql.schema.DataFetchingEnvironment;
+import java.util.concurrent.CompletableFuture;
+import javax.annotation.Nonnull;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Resolver for fetching the latest product update information.
+ *
+ * Attempts to fetch from remote URL (with 6-hour cache), falling back to bundled classpath
+ * resource if unavailable. Returns null only if both remote and fallback fail, or if the feature is
+ * disabled.
+ *
+ *
Supports an optional {@code refreshCache} argument to clear the cache before fetching.
+ */
+@Slf4j
+public class ProductUpdateResolver implements DataFetcher> {
+
+ private final ProductUpdateService _productUpdateService;
+ private final FeatureFlags _featureFlags;
+
+ public ProductUpdateResolver(
+ @Nonnull final ProductUpdateService productUpdateService,
+ @Nonnull final FeatureFlags featureFlags) {
+ this._productUpdateService = productUpdateService;
+ this._featureFlags = featureFlags;
+ }
+
+ @Override
+ public CompletableFuture get(DataFetchingEnvironment environment) {
+ final Boolean refreshCache = environment.getArgument("refreshCache");
+ final boolean shouldRefresh = refreshCache != null && refreshCache;
+
+ return CompletableFuture.supplyAsync(
+ () -> {
+ try {
+ if (!_featureFlags.isShowProductUpdates()) {
+ log.debug("Product updates feature is disabled");
+ return null;
+ }
+
+ if (shouldRefresh) {
+ log.info("Clearing product update cache and refetching");
+ _productUpdateService.clearCache();
+ }
+
+ ProductUpdate productUpdate =
+ ProductUpdateParser.parseProductUpdate(
+ _productUpdateService.getLatestProductUpdate());
+
+ if (productUpdate != null) {
+ log.debug(
+ "Successfully {} product update: {}",
+ shouldRefresh ? "refreshed" : "resolved",
+ productUpdate.getId());
+ }
+
+ return productUpdate;
+
+ } catch (Exception e) {
+ log.warn(
+ "Failed to {} product update: {}",
+ shouldRefresh ? "refresh" : "resolve",
+ e.getMessage());
+ log.debug("Product update error details", e);
+ return null;
+ }
+ });
+ }
+}
diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql
index 780c3b5bc9..f0964f5701 100644
--- a/datahub-graphql-core/src/main/resources/app.graphql
+++ b/datahub-graphql-core/src/main/resources/app.graphql
@@ -27,6 +27,12 @@ extend type Query {
Global settings related to the home page for an instance
"""
globalHomePageSettings: GlobalHomePageSettings
+
+ """
+ Fetch the latest product update information from configured JSON source.
+ Returns null if the JSON source is unavailable or feature is disabled.
+ """
+ latestProductUpdate(refreshCache: Boolean = false): ProductUpdate
}
extend type Mutation {
@@ -909,3 +915,44 @@ type GlobalHomePageSettings {
"""
defaultTemplate: DataHubPageTemplate
}
+
+"""
+Product update information fetched from remote JSON source
+"""
+type ProductUpdate {
+ """
+ Whether this update is enabled and should be shown
+ """
+ enabled: Boolean!
+
+ """
+ Unique identifier for this update (e.g., "v1.2.1")
+ Version changes trigger re-display for users who dismissed previous versions
+ """
+ id: String!
+
+ """
+ Title of the update notification
+ """
+ title: String!
+
+ """
+ Optional URL to an image to display with the update
+ """
+ image: String
+
+ """
+ Optional description text for the update
+ """
+ description: String
+
+ """
+ Call-to-action button text (e.g., "Read updates")
+ """
+ ctaText: String!
+
+ """
+ Call-to-action link URL, with telemetry client ID appended
+ """
+ ctaLink: String!
+}
diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java
new file mode 100644
index 0000000000..22783965d7
--- /dev/null
+++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java
@@ -0,0 +1,386 @@
+package com.linkedin.datahub.graphql.resolvers.config;
+
+import static org.testng.Assert.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linkedin.datahub.graphql.generated.ProductUpdate;
+import java.util.Optional;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+@SuppressWarnings("null")
+public class ProductUpdateParserTest {
+
+ private ObjectMapper objectMapper;
+
+ @BeforeMethod
+ public void setupTest() {
+ objectMapper = new ObjectMapper();
+ }
+
+ @Test
+ public void testParseProductUpdateSuccess() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": \"New features\","
+ + "\"image\": \"https://example.com/image.png\","
+ + "\"ctaText\": \"Learn more\","
+ + "\"ctaLink\": \"https://example.com\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertTrue(result.getEnabled());
+ assertEquals(result.getId(), "v1.0.0");
+ assertEquals(result.getTitle(), "What's New");
+ assertEquals(result.getDescription(), "New features");
+ assertEquals(result.getImage(), "https://example.com/image.png");
+ assertEquals(result.getCtaText(), "Learn more");
+ assertEquals(result.getCtaLink(), "https://example.com");
+ }
+
+ @Test
+ public void testParseProductUpdateMinimalFields() throws Exception {
+ String jsonString =
+ "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertTrue(result.getEnabled());
+ assertEquals(result.getId(), "v1.0.0");
+ assertEquals(result.getTitle(), "What's New");
+ assertNull(result.getDescription());
+ assertNull(result.getImage());
+ assertEquals(result.getCtaText(), "Learn more");
+ assertEquals(result.getCtaLink(), "");
+ }
+
+ @Test
+ public void testParseProductUpdateWithCustomCta() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v2.0.0\","
+ + "\"title\": \"Major Update\","
+ + "\"ctaText\": \"View Release Notes\","
+ + "\"ctaLink\": \"https://docs.example.com/v2.0\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertEquals(result.getCtaText(), "View Release Notes");
+ assertEquals(result.getCtaLink(), "https://docs.example.com/v2.0");
+ }
+
+ @Test
+ public void testParseProductUpdateEmptyOptional() {
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.empty());
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdateMissingEnabledField() throws Exception {
+ String jsonString = "{" + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdateMissingIdField() throws Exception {
+ String jsonString = "{" + "\"enabled\": true," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdateMissingTitleField() throws Exception {
+ String jsonString = "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdateDisabledInJson() throws Exception {
+ String jsonString =
+ "{" + "\"enabled\": false," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdateNullValues() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": null,"
+ + "\"image\": null"
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getDescription());
+ assertNotNull(result.getImage());
+ assertEquals(result.getDescription(), "null");
+ assertEquals(result.getImage(), "null");
+ }
+
+ @Test
+ public void testParseProductUpdateEmptyStrings() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"\","
+ + "\"title\": \"\","
+ + "\"description\": \"\","
+ + "\"ctaText\": \"\","
+ + "\"ctaLink\": \"\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getId());
+ assertNotNull(result.getTitle());
+ assertNotNull(result.getDescription());
+ assertNotNull(result.getCtaText());
+ assertNotNull(result.getCtaLink());
+ assertEquals(result.getId(), "");
+ assertEquals(result.getTitle(), "");
+ assertEquals(result.getDescription(), "");
+ assertEquals(result.getCtaText(), "");
+ assertEquals(result.getCtaLink(), "");
+ }
+
+ @Test
+ public void testParseProductUpdateEnabledFalse() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": false,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": \"This should not be shown\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdatePartialOptionalFields() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": \"Has description but no image\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getDescription());
+ assertEquals(result.getDescription(), "Has description but no image");
+ assertNull(result.getImage());
+ }
+
+ @Test
+ public void testParseProductUpdateImageOnly() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"image\": \"https://example.com/image.png\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNull(result.getDescription());
+ assertNotNull(result.getImage());
+ assertEquals(result.getImage(), "https://example.com/image.png");
+ }
+
+ @Test
+ public void testParseProductUpdateBooleanTypesForEnabled() throws Exception {
+ String jsonString =
+ "{" + "\"enabled\": 1," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getEnabled());
+ assertTrue(result.getEnabled());
+ }
+
+ @Test
+ public void testParseProductUpdateZeroEnabledField() throws Exception {
+ String jsonString =
+ "{" + "\"enabled\": 0," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNull(result);
+ }
+
+ @Test
+ public void testParseProductUpdateExtraFields() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"unknownField\": \"Should be ignored\","
+ + "\"anotherField\": 12345"
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getId());
+ assertNotNull(result.getTitle());
+ assertEquals(result.getId(), "v1.0.0");
+ assertEquals(result.getTitle(), "What's New");
+ }
+
+ @Test
+ public void testParseProductUpdateCtaTextWithoutLink() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"ctaText\": \"Click here\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getCtaText());
+ assertNotNull(result.getCtaLink());
+ assertEquals(result.getCtaText(), "Click here");
+ assertEquals(result.getCtaLink(), "");
+ }
+
+ @Test
+ public void testParseProductUpdateCtaLinkWithoutText() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"ctaLink\": \"https://example.com\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getCtaText());
+ assertNotNull(result.getCtaLink());
+ assertEquals(result.getCtaText(), "Learn more");
+ assertEquals(result.getCtaLink(), "https://example.com");
+ }
+
+ @Test
+ public void testParseProductUpdateSpecialCharactersInFields() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0-rc.1+build.123\","
+ + "\"title\": \"What's New: & \\\"Quoted\\\"\","
+ + "\"description\": \"Line 1\\nLine 2\\tTabbed\","
+ + "\"ctaLink\": \"https://example.com/path?query=value&other=123#anchor\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getId());
+ assertNotNull(result.getTitle());
+ assertNotNull(result.getDescription());
+ assertNotNull(result.getCtaLink());
+ assertEquals(result.getId(), "v1.0.0-rc.1+build.123");
+ assertEquals(result.getTitle(), "What's New: & \"Quoted\"");
+ assertEquals(result.getDescription(), "Line 1\nLine 2\tTabbed");
+ assertEquals(result.getCtaLink(), "https://example.com/path?query=value&other=123#anchor");
+ }
+
+ @Test
+ public void testParseProductUpdateUnicodeCharacters() throws Exception {
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"新機能 🎉\","
+ + "\"description\": \"Nouveau fonctionnalités 中文测试\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getTitle());
+ assertNotNull(result.getDescription());
+ assertEquals(result.getTitle(), "新機能 🎉");
+ assertEquals(result.getDescription(), "Nouveau fonctionnalités 中文测试");
+ }
+
+ @Test
+ public void testParseProductUpdateLongStrings() throws Exception {
+ String longDescription = "x".repeat(10000);
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": \""
+ + longDescription
+ + "\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+
+ ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
+
+ assertNotNull(result);
+ assertNotNull(result.getDescription());
+ assertEquals(result.getDescription().length(), 10000);
+ }
+}
diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java
new file mode 100644
index 0000000000..dbacd1b885
--- /dev/null
+++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java
@@ -0,0 +1,360 @@
+package com.linkedin.datahub.graphql.resolvers.config;
+
+import static org.mockito.Mockito.*;
+import static org.testng.Assert.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linkedin.datahub.graphql.featureflags.FeatureFlags;
+import com.linkedin.datahub.graphql.generated.ProductUpdate;
+import com.linkedin.metadata.service.ProductUpdateService;
+import graphql.schema.DataFetchingEnvironment;
+import java.util.Optional;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+public class ProductUpdateResolverTest {
+
+ @Mock private ProductUpdateService mockProductUpdateService;
+ @Mock private FeatureFlags mockFeatureFlags;
+ @Mock private DataFetchingEnvironment mockDataFetchingEnvironment;
+
+ private ProductUpdateResolver resolver;
+ private ObjectMapper objectMapper;
+
+ @BeforeMethod
+ public void setupTest() {
+ MockitoAnnotations.openMocks(this);
+ objectMapper = new ObjectMapper();
+ resolver = new ProductUpdateResolver(mockProductUpdateService, mockFeatureFlags);
+ }
+
+ @Test
+ public void testGetProductUpdateSuccess() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": \"New features\","
+ + "\"image\": \"https://example.com/image.png\","
+ + "\"ctaText\": \"Learn more\","
+ + "\"ctaLink\": \"https://example.com\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ verify(mockProductUpdateService).getLatestProductUpdate();
+ verify(mockProductUpdateService, never()).clearCache(); // Should NOT clear cache
+ assertNotNull(result);
+ assertTrue(result.getEnabled());
+ assertEquals(result.getId(), "v1.0.0");
+ assertEquals(result.getTitle(), "What's New");
+ assertEquals(result.getDescription(), "New features");
+ assertEquals(result.getImage(), "https://example.com/image.png");
+ assertEquals(result.getCtaText(), "Learn more");
+ assertEquals(result.getCtaLink(), "https://example.com");
+ }
+
+ @Test
+ public void testGetProductUpdateMinimalFields() throws Exception {
+ // Setup - only required fields
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString =
+ "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ assertNotNull(result);
+ assertTrue(result.getEnabled());
+ assertEquals(result.getId(), "v1.0.0");
+ assertEquals(result.getTitle(), "What's New");
+ assertNull(result.getDescription());
+ assertNull(result.getImage());
+ assertEquals(result.getCtaText(), "Learn more"); // default value
+ assertEquals(result.getCtaLink(), ""); // default value
+ }
+
+ @Test
+ public void testGetProductUpdateFeatureDisabled() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(false);
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ verify(mockProductUpdateService, never()).getLatestProductUpdate();
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateNoJsonAvailable() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.empty());
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ verify(mockProductUpdateService).getLatestProductUpdate();
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateMissingEnabledField() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString = "{" + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateMissingIdField() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString = "{" + "\"enabled\": true," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateMissingTitleField() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString = "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateDisabledInJson() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString =
+ "{" + "\"enabled\": false," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateExceptionHandling() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+ when(mockProductUpdateService.getLatestProductUpdate())
+ .thenThrow(new RuntimeException("Service error"));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify - should handle exception gracefully and return null
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateWithCustomCta() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v2.0.0\","
+ + "\"title\": \"Major Update\","
+ + "\"ctaText\": \"View Release Notes\","
+ + "\"ctaLink\": \"https://docs.example.com/v2.0\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ assertNotNull(result);
+ assertEquals(result.getCtaText(), "View Release Notes");
+ assertEquals(result.getCtaLink(), "https://docs.example.com/v2.0");
+ }
+
+ @Test
+ public void testGetProductUpdateNullValues() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": null,"
+ + "\"image\": null"
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify - Jackson's asText() converts null JSON values to string "null"
+ assertNotNull(result);
+ assertEquals(result.getDescription(), "null");
+ assertEquals(result.getImage(), "null");
+ }
+
+ @Test
+ public void testGetProductUpdateUsesCache() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+ when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(null);
+
+ String jsonString =
+ "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute multiple times
+ resolver.get(mockDataFetchingEnvironment).get();
+ resolver.get(mockDataFetchingEnvironment).get();
+ resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify - service is called each time (caching happens in the service layer)
+ verify(mockProductUpdateService, times(3)).getLatestProductUpdate();
+ // But clearCache should never be called by this resolver
+ verify(mockProductUpdateService, never()).clearCache();
+ }
+
+ @Test
+ public void testGetProductUpdateWithRefreshCacheTrue() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+ when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(true);
+
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"v1.0.0\","
+ + "\"title\": \"What's New\","
+ + "\"description\": \"New features\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ verify(mockProductUpdateService).clearCache(); // Should clear cache first
+ verify(mockProductUpdateService).getLatestProductUpdate();
+ assertNotNull(result);
+ assertEquals(result.getId(), "v1.0.0");
+ assertEquals(result.getTitle(), "What's New");
+ }
+
+ @Test
+ public void testGetProductUpdateWithRefreshCacheFalse() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+ when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(false);
+
+ String jsonString =
+ "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify
+ verify(mockProductUpdateService, never()).clearCache(); // Should NOT clear cache
+ verify(mockProductUpdateService).getLatestProductUpdate();
+ assertNotNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateRefreshCacheWhenFeatureDisabled() throws Exception {
+ // Setup - even if refreshCache is true, feature flag should be checked first
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(false);
+ when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(true);
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify - nothing should be called because feature is disabled
+ verify(mockProductUpdateService, never()).clearCache();
+ verify(mockProductUpdateService, never()).getLatestProductUpdate();
+ assertNull(result);
+ }
+
+ @Test
+ public void testGetProductUpdateEmptyStrings() throws Exception {
+ // Setup
+ when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true);
+
+ String jsonString =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"\","
+ + "\"title\": \"\","
+ + "\"description\": \"\","
+ + "\"ctaText\": \"\","
+ + "\"ctaLink\": \"\""
+ + "}";
+ JsonNode jsonNode = objectMapper.readTree(jsonString);
+ when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode));
+
+ // Execute
+ ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get();
+
+ // Verify - empty strings should be accepted
+ assertNotNull(result);
+ assertEquals(result.getId(), "");
+ assertEquals(result.getTitle(), "");
+ assertEquals(result.getDescription(), "");
+ assertEquals(result.getCtaText(), "");
+ assertEquals(result.getCtaLink(), "");
+ }
+}
diff --git a/datahub-web-react/src/app/shared/product/update/hooks.ts b/datahub-web-react/src/app/shared/product/update/hooks.ts
index 3fd277f67a..4a6f815fb6 100644
--- a/datahub-web-react/src/app/shared/product/update/hooks.ts
+++ b/datahub-web-react/src/app/shared/product/update/hooks.ts
@@ -46,13 +46,15 @@ export function useIsProductAnnouncementVisible(update: ProductUpdate): ProductA
fetchPolicy: 'cache-first',
});
- if (loading || error) {
+ // If query is loading or has an error or userUrn is not loaded yet, don't show the announcement (wait for user context to load)
+ if (!userUrn || loading || error) {
return {
visible: false,
refetch,
};
}
+ // Show announcement if the step state doesn't exist (user hasn't dismissed it)
const visible =
(data?.batchGetStepStates?.results &&
!data?.batchGetStepStates?.results?.some((result) => result?.id === productUpdateStepId)) ||
diff --git a/datahub-web-react/src/app/shared/product/update/latestUpdate.ts b/datahub-web-react/src/app/shared/product/update/latestUpdate.ts
index 7532404f40..a3c2fe81e8 100644
--- a/datahub-web-react/src/app/shared/product/update/latestUpdate.ts
+++ b/datahub-web-react/src/app/shared/product/update/latestUpdate.ts
@@ -15,10 +15,10 @@ export type ProductUpdate = {
// TODO: Migrate this to be served via an aspect!
export const latestUpdate: ProductUpdate = {
enabled: true,
- id: 'v1.2.0', // Very important, when changed it will trigger the announcement to be re-displayed for a user.
+ id: 'v1.2.1', // Very important, when changed it will trigger the announcement to be re-displayed for a user.
title: "What's New In DataHub",
- description: 'Explore version v1.2.0',
+ description: 'Explore version v1.2.1',
image: SampleImage, // Import and use image.,
ctaText: 'Read updates',
- ctaLink: 'https://docs.datahub.com/docs/releases#v1-2-0',
+ ctaLink: 'https://docs.datahub.com/docs/releases#v1-2-1',
};
diff --git a/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java b/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java
index 6ebe72a7d2..3b17b7853c 100644
--- a/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java
+++ b/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java
@@ -504,6 +504,8 @@ public class PropertiesCollectorConfigurationTest extends AbstractTestNGSpringCo
"featureFlags.showManageTags",
"featureFlags.showNavBarRedesign",
"featureFlags.showProductUpdates",
+ "featureFlags.productUpdatesJsonUrl",
+ "featureFlags.productUpdatesJsonFallbackResource",
"featureFlags.showStatsTabRedesign",
"featureFlags.showSearchBarAutocompleteRedesign",
"featureFlags.showSearchFiltersV2",
diff --git a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
index 27671e30a7..8e59b5c6f1 100644
--- a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
+++ b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
@@ -46,6 +46,8 @@ public class FeatureFlags {
private boolean showHomePageRedesign = false;
private boolean lineageGraphV3 = true;
private boolean showProductUpdates = false;
+ private String productUpdatesJsonUrl;
+ private String productUpdatesJsonFallbackResourceUrl;
private boolean logicalModelsEnabled = false;
private boolean showHomepageUserRole = false;
private boolean assetSummaryPageV1 = false;
diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml
index a2ef85a3f3..1d98bc7317 100644
--- a/metadata-service/configuration/src/main/resources/application.yaml
+++ b/metadata-service/configuration/src/main/resources/application.yaml
@@ -847,6 +847,8 @@ featureFlags:
showHomePageRedesign: ${SHOW_HOME_PAGE_REDESIGN:false} # If turned on, show the re-designed home page
lineageGraphV3: ${LINEAGE_GRAPH_V3:false} # Enables the redesign of the lineage v2 graph
showProductUpdates: ${SHOW_PRODUCT_UPDATES:true} # Whether to show in-product update popover on new updates.
+ productUpdatesJsonUrl: ${PRODUCT_UPDATES_JSON_URL:https://raw.githubusercontent.com/datahub-project/datahub/master/metadata-service/configuration/src/main/resources/product-update.json} # URL to fetch product updates JSON from remote source.
+ productUpdatesJsonFallbackResource: ${PRODUCT_UPDATES_JSON_FALLBACK_RESOURCE:product-update.json} # Classpath resource to use as fallback when remote fetch fails.
logicalModelsEnabled: ${LOGICAL_MODELS_ENABLED:false} # Enables logical models feature
showHomepageUserRole: ${SHOW_HOMEPAGE_USER_ROLE:false} # Enables displaying the homepage user role underneath the name. Only relevant for custom home page
fineGrainedLineageNotAllowedForPlatforms: ${FINE_GRAINED_LINEAGE_NOT_ALLOWED_FOR_PLATFORMS:} # Comma separated list of platforms for which schemaFields entity edges will not be allowed to be created. for example: "hdfs, s3"
diff --git a/metadata-service/configuration/src/main/resources/product-update.json b/metadata-service/configuration/src/main/resources/product-update.json
new file mode 100644
index 0000000000..3db6eb8db8
--- /dev/null
+++ b/metadata-service/configuration/src/main/resources/product-update.json
@@ -0,0 +1,8 @@
+{
+ "enabled": true,
+ "id": "v1.2.1",
+ "title": "What's New In DataHub",
+ "description": "Explore version v1.2.1",
+ "ctaText": "Read updates",
+ "ctaLink": "https://docs.datahub.com/docs/releases#v1-2-1"
+}
diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java
new file mode 100644
index 0000000000..155ffc9b00
--- /dev/null
+++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java
@@ -0,0 +1,188 @@
+package com.linkedin.metadata.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Service for fetching product update information from a remote JSON source.
+ *
+ * This service attempts to fetch from a remote URL and falls back to a bundled classpath
+ * resource if the remote fetch fails. This ensures product updates work in both internet-connected
+ * and air-gapped deployments.
+ *
+ *
The service uses an in-memory cache with a 6-hour TTL to reduce network requests.
+ */
+@Slf4j
+public class ProductUpdateService {
+
+ private static final String CACHE_KEY = "product_update_json";
+ private static final long CACHE_TTL_HOURS = 6;
+ private static final int HTTP_TIMEOUT_SECONDS = 10;
+
+ private final String jsonUrl;
+ private final String fallbackResource;
+ private final HttpClient httpClient;
+ private final ObjectMapper objectMapper;
+ private final Cache cache;
+
+ public ProductUpdateService(
+ @Nullable final String jsonUrl, @Nonnull final String fallbackResource) {
+ this(
+ jsonUrl,
+ fallbackResource,
+ HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)).build());
+ }
+
+ public ProductUpdateService(
+ @Nullable final String jsonUrl,
+ @Nonnull final String fallbackResource,
+ @Nonnull final HttpClient httpClient) {
+ this.jsonUrl = jsonUrl;
+ this.fallbackResource = fallbackResource;
+ this.httpClient = httpClient;
+ this.objectMapper = new ObjectMapper();
+ this.cache =
+ CacheBuilder.newBuilder().expireAfterWrite(CACHE_TTL_HOURS, TimeUnit.HOURS).build();
+
+ log.info(
+ "ProductUpdateService initialized with URL: {}, fallback resource: {}",
+ jsonUrl,
+ fallbackResource);
+ }
+
+ /**
+ * Fetches the latest product update JSON from the configured URL, falling back to classpath
+ * resource if remote fetch fails.
+ *
+ * This method gracefully handles all errors by returning a fallback from the classpath. This
+ * ensures product updates work in both internet-connected and air-gapped deployments.
+ *
+ *
Remote results are cached for 6 hours to reduce network load.
+ *
+ * @return Optional containing the parsed JSON node from remote or fallback, empty Optional only
+ * if both fail
+ */
+ @Nonnull
+ public Optional getLatestProductUpdate() {
+ // Try remote fetch if URL is configured
+ if (jsonUrl != null && !jsonUrl.trim().isEmpty()) {
+ try {
+ // Check cache first
+ JsonNode cachedValue = cache.getIfPresent(CACHE_KEY);
+ if (cachedValue != null) {
+ log.debug("Returning cached product update JSON");
+ return Optional.of(cachedValue);
+ }
+
+ // Fetch from remote source
+ log.debug("Fetching product update JSON from: {}", jsonUrl);
+ JsonNode result = fetchFromRemote();
+
+ if (result != null) {
+ cache.put(CACHE_KEY, result);
+ log.info("Successfully fetched and cached product update JSON from remote");
+ return Optional.of(result);
+ }
+ } catch (Exception e) {
+ log.warn(
+ "Failed to fetch product update JSON from {}: {}, falling back to classpath resource",
+ jsonUrl,
+ e.getMessage());
+ log.debug("Product update fetch error details", e);
+ }
+ } else {
+ log.debug("Product update JSON URL not configured, using classpath fallback");
+ }
+
+ // Fallback to classpath resource
+ return loadFromClasspath();
+ }
+
+ /**
+ * Fetches the JSON from the remote URL.
+ *
+ * @return the parsed JSON node, or null if fetch failed
+ */
+ @Nullable
+ private JsonNode fetchFromRemote() {
+ try {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(URI.create(jsonUrl))
+ .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
+ .GET()
+ .build();
+
+ HttpResponse response =
+ httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ log.warn("Product update JSON fetch returned non-200 status: {}", response.statusCode());
+ return null;
+ }
+
+ String body = response.body();
+ if (body == null || body.trim().isEmpty()) {
+ log.warn("Product update JSON fetch returned empty body");
+ return null;
+ }
+
+ JsonNode jsonNode = objectMapper.readTree(body);
+ log.info("Successfully parsed product update JSON: {}", jsonNode.toString());
+ return jsonNode;
+
+ } catch (Exception e) {
+ log.warn("Error fetching product update JSON: {}", e.getMessage());
+ log.debug("Fetch error details", e);
+ return null;
+ }
+ }
+
+ /**
+ * Loads the product update JSON from the classpath.
+ *
+ * @return Optional containing the parsed JSON node, empty if resource not found or invalid
+ */
+ @Nonnull
+ private Optional loadFromClasspath() {
+ try {
+ log.debug("Loading product update JSON from classpath: {}", fallbackResource);
+ var inputStream = getClass().getClassLoader().getResourceAsStream(fallbackResource);
+
+ if (inputStream == null) {
+ log.error("Fallback resource not found on classpath: {}", fallbackResource);
+ return Optional.empty();
+ }
+
+ JsonNode jsonNode = objectMapper.readTree(inputStream);
+ log.info("Successfully loaded product update JSON from classpath fallback");
+ return Optional.of(jsonNode);
+
+ } catch (Exception e) {
+ log.error(
+ "Failed to load product update JSON from classpath resource {}: {}",
+ fallbackResource,
+ e.getMessage(),
+ e);
+ return Optional.empty();
+ }
+ }
+
+ /** Clears the cache. Useful for testing or forcing a refresh. */
+ public void clearCache() {
+ cache.invalidateAll();
+ log.debug("Product update cache cleared");
+ }
+}
diff --git a/metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java b/metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java
new file mode 100644
index 0000000000..f311c5b8ed
--- /dev/null
+++ b/metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java
@@ -0,0 +1,282 @@
+package com.linkedin.metadata.service;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.testng.Assert.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Optional;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for ProductUpdateService focusing on HTTP fetching, caching behavior, and fallback logic.
+ */
+public class ProductUpdateServiceTest {
+
+ private static final String TEST_RESOURCE_PATH = "product-update-for-test.json";
+ private static final String TEST_URL = "https://example.com/product-update.json";
+
+ private static final String MOCK_PRODUCT_JSON_V1 =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"remote-v1\","
+ + "\"title\": \"Remote Update V1\","
+ + "\"description\": \"First remote version\""
+ + "}";
+
+ private static final String MOCK_PRODUCT_JSON_V2 =
+ "{"
+ + "\"enabled\": true,"
+ + "\"id\": \"remote-v2\","
+ + "\"title\": \"Remote Update V2\","
+ + "\"description\": \"Second remote version\""
+ + "}";
+
+ @Mock private HttpClient mockHttpClient;
+ @Mock private HttpResponse mockHttpResponse;
+
+ private ProductUpdateService service;
+ private ObjectMapper objectMapper;
+
+ @BeforeMethod
+ public void setupTest() {
+ MockitoAnnotations.openMocks(this);
+ objectMapper = new ObjectMapper();
+ }
+
+ @Test
+ public void testSuccessfulRemoteFetchIsCached() throws Exception {
+ // Setup - mock successful HTTP response
+ when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
+ when(mockHttpResponse.statusCode()).thenReturn(200);
+ when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1);
+
+ service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute - fetch multiple times
+ Optional result1 = service.getLatestProductUpdate();
+ Optional result2 = service.getLatestProductUpdate();
+ Optional result3 = service.getLatestProductUpdate();
+
+ // Verify - HTTP should only be called once (cached after first fetch)
+ verify(mockHttpClient, times(1)).send(any(HttpRequest.class), any());
+
+ // All results should be the remote version
+ assertTrue(result1.isPresent());
+ assertTrue(result2.isPresent());
+ assertTrue(result3.isPresent());
+ assertEquals(result1.get().get("id").asText(), "remote-v1");
+ assertEquals(result2.get().get("id").asText(), "remote-v1");
+ assertEquals(result3.get().get("id").asText(), "remote-v1");
+ }
+
+ @Test
+ public void testClearCacheRefetchesFromRemote() throws Exception {
+ // Setup - mock HTTP to return V1 first, then V2
+ when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
+ when(mockHttpResponse.statusCode()).thenReturn(200);
+ when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1).thenReturn(MOCK_PRODUCT_JSON_V2);
+
+ service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute - fetch, clear cache, fetch again
+ Optional result1 = service.getLatestProductUpdate();
+ service.clearCache();
+ Optional result2 = service.getLatestProductUpdate();
+
+ // Verify - HTTP should be called twice (once before clear, once after)
+ verify(mockHttpClient, times(2)).send(any(HttpRequest.class), any());
+
+ // Results should reflect the different versions
+ assertTrue(result1.isPresent());
+ assertTrue(result2.isPresent());
+ assertEquals(result1.get().get("id").asText(), "remote-v1");
+ assertEquals(result2.get().get("id").asText(), "remote-v2");
+ }
+
+ @Test
+ public void testRemoteFetchFailsFallsBackToClasspath() throws Exception {
+ // Setup - mock HTTP failure
+ when(mockHttpClient.send(any(HttpRequest.class), any()))
+ .thenThrow(new IOException("Connection failed"));
+
+ service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should fall back to classpath resource
+ assertTrue(result.isPresent());
+ assertEquals(result.get().get("id").asText(), "test-product-update");
+ }
+
+ @Test
+ public void testRemoteReturnsNon200FallsBackToClasspath() throws Exception {
+ // Setup - mock HTTP 404 response
+ when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
+ when(mockHttpResponse.statusCode()).thenReturn(404);
+
+ service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should fall back to classpath resource
+ assertTrue(result.isPresent());
+ assertEquals(result.get().get("id").asText(), "test-product-update");
+ }
+
+ @Test
+ public void testRemoteReturnsEmptyBodyFallsBackToClasspath() throws Exception {
+ // Setup - mock empty response body
+ when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
+ when(mockHttpResponse.statusCode()).thenReturn(200);
+ when(mockHttpResponse.body()).thenReturn("");
+
+ service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should fall back to classpath resource
+ assertTrue(result.isPresent());
+ assertEquals(result.get().get("id").asText(), "test-product-update");
+ }
+
+ @Test
+ public void testAfterRemoteFailsClearCacheRetries() throws Exception {
+ // Setup - mock HTTP to fail first, then succeed
+ when(mockHttpClient.send(any(HttpRequest.class), any()))
+ .thenThrow(new IOException("Connection failed"))
+ .thenReturn(mockHttpResponse);
+ when(mockHttpResponse.statusCode()).thenReturn(200);
+ when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1);
+
+ service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute - first fetch fails and falls back, clear cache, fetch again succeeds
+ Optional result1 = service.getLatestProductUpdate();
+ service.clearCache();
+ Optional result2 = service.getLatestProductUpdate();
+
+ // Verify - HTTP should be attempted twice
+ verify(mockHttpClient, times(2)).send(any(HttpRequest.class), any());
+
+ // First result should be fallback, second should be remote
+ assertTrue(result1.isPresent());
+ assertTrue(result2.isPresent());
+ assertEquals(result1.get().get("id").asText(), "test-product-update");
+ assertEquals(result2.get().get("id").asText(), "remote-v1");
+ }
+
+ @Test
+ public void testNoUrlConfiguredUsesFallback() throws Exception {
+ // Setup - no URL configured
+ service = new ProductUpdateService(null, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should not attempt HTTP, should use fallback
+ verify(mockHttpClient, never()).send(any(HttpRequest.class), any());
+ assertTrue(result.isPresent());
+ assertEquals(result.get().get("id").asText(), "test-product-update");
+ }
+
+ @Test
+ public void testEmptyUrlUsesFallback() throws Exception {
+ // Setup - empty URL
+ service = new ProductUpdateService("", TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should not attempt HTTP, should use fallback
+ verify(mockHttpClient, never()).send(any(HttpRequest.class), any());
+ assertTrue(result.isPresent());
+ assertEquals(result.get().get("id").asText(), "test-product-update");
+ }
+
+ @Test
+ public void testWhitespaceUrlUsesFallback() throws Exception {
+ // Setup - whitespace URL
+ service = new ProductUpdateService(" ", TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should not attempt HTTP, should use fallback
+ verify(mockHttpClient, never()).send(any(HttpRequest.class), any());
+ assertTrue(result.isPresent());
+ assertEquals(result.get().get("id").asText(), "test-product-update");
+ }
+
+ @Test
+ public void testNonExistentFallbackReturnsEmpty() throws Exception {
+ // Setup - no URL and non-existent fallback
+ service = new ProductUpdateService(null, "non-existent-resource.json", mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - should return empty when fallback doesn't exist
+ assertNotNull(result);
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ public void testMultipleInstancesHaveIndependentCaches() throws Exception {
+ // Setup - two service instances with mocked responses
+ when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
+ when(mockHttpResponse.statusCode()).thenReturn(200);
+ when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1);
+
+ ProductUpdateService service1 =
+ new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+ ProductUpdateService service2 =
+ new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute - fetch from both, clear one, fetch again
+ service1.getLatestProductUpdate();
+ service2.getLatestProductUpdate();
+ service1.clearCache();
+ service1.getLatestProductUpdate();
+ service2.getLatestProductUpdate();
+
+ // Verify - service1 should fetch twice (initial + after clear), service2 only once (cached)
+ // Total HTTP calls: 3 (1 from service1 initial, 1 from service2 initial, 1 from service1 after
+ // clear)
+ verify(mockHttpClient, times(3)).send(any(HttpRequest.class), any());
+ }
+
+ @Test
+ public void testFallbackJsonStructure() throws Exception {
+ // Setup
+ service = new ProductUpdateService(null, TEST_RESOURCE_PATH, mockHttpClient);
+
+ // Execute
+ Optional result = service.getLatestProductUpdate();
+
+ // Verify - validate expected JSON structure from test resource
+ assertTrue(result.isPresent());
+ JsonNode json = result.get();
+
+ assertTrue(json.isObject());
+ assertTrue(json.has("id"));
+ assertTrue(json.has("enabled"));
+ assertTrue(json.has("title"));
+ assertTrue(json.has("description"));
+
+ assertEquals(json.get("id").asText(), "test-product-update");
+ assertTrue(json.get("enabled").asBoolean());
+ assertEquals(json.get("title").asText(), "Test Product Update");
+ }
+}
diff --git a/metadata-service/services/src/test/resources/product-update-for-test.json b/metadata-service/services/src/test/resources/product-update-for-test.json
new file mode 100644
index 0000000000..198603dd8d
--- /dev/null
+++ b/metadata-service/services/src/test/resources/product-update-for-test.json
@@ -0,0 +1,8 @@
+{
+ "enabled": true,
+ "id": "test-product-update",
+ "title": "Test Product Update",
+ "description": "This is a test fallback product update",
+ "ctaText": "Learn more",
+ "ctaLink": "https://example.com"
+}