mirror of
https://github.com/datahub-project/datahub.git
synced 2025-10-28 09:23:25 +00:00
SE-123: added product update resolver (#14980)
This commit is contained in:
parent
edcc8bca76
commit
fd00f04b0f
@ -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(
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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<JsonNode> 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;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Supports an optional {@code refreshCache} argument to clear the cache before fetching.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ProductUpdateResolver implements DataFetcher<CompletableFuture<ProductUpdate>> {
|
||||
|
||||
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<ProductUpdate> 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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!
|
||||
}
|
||||
|
||||
@ -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: <Special> & \\\"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: <Special> & \"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);
|
||||
}
|
||||
}
|
||||
@ -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(), "");
|
||||
}
|
||||
}
|
||||
@ -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)) ||
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String, JsonNode> 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<JsonNode> 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<String> 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<JsonNode> 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");
|
||||
}
|
||||
}
|
||||
@ -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<String> 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.<String>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<JsonNode> result1 = service.getLatestProductUpdate();
|
||||
Optional<JsonNode> result2 = service.getLatestProductUpdate();
|
||||
Optional<JsonNode> result3 = service.getLatestProductUpdate();
|
||||
|
||||
// Verify - HTTP should only be called once (cached after first fetch)
|
||||
verify(mockHttpClient, times(1)).<String>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.<String>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<JsonNode> result1 = service.getLatestProductUpdate();
|
||||
service.clearCache();
|
||||
Optional<JsonNode> result2 = service.getLatestProductUpdate();
|
||||
|
||||
// Verify - HTTP should be called twice (once before clear, once after)
|
||||
verify(mockHttpClient, times(2)).<String>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.<String>send(any(HttpRequest.class), any()))
|
||||
.thenThrow(new IOException("Connection failed"));
|
||||
|
||||
service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
|
||||
|
||||
// Execute
|
||||
Optional<JsonNode> 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.<String>send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
|
||||
when(mockHttpResponse.statusCode()).thenReturn(404);
|
||||
|
||||
service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
|
||||
|
||||
// Execute
|
||||
Optional<JsonNode> 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.<String>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<JsonNode> 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.<String>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<JsonNode> result1 = service.getLatestProductUpdate();
|
||||
service.clearCache();
|
||||
Optional<JsonNode> result2 = service.getLatestProductUpdate();
|
||||
|
||||
// Verify - HTTP should be attempted twice
|
||||
verify(mockHttpClient, times(2)).<String>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<JsonNode> result = service.getLatestProductUpdate();
|
||||
|
||||
// Verify - should not attempt HTTP, should use fallback
|
||||
verify(mockHttpClient, never()).<String>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<JsonNode> result = service.getLatestProductUpdate();
|
||||
|
||||
// Verify - should not attempt HTTP, should use fallback
|
||||
verify(mockHttpClient, never()).<String>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<JsonNode> result = service.getLatestProductUpdate();
|
||||
|
||||
// Verify - should not attempt HTTP, should use fallback
|
||||
verify(mockHttpClient, never()).<String>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<JsonNode> 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.<String>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)).<String>send(any(HttpRequest.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFallbackJsonStructure() throws Exception {
|
||||
// Setup
|
||||
service = new ProductUpdateService(null, TEST_RESOURCE_PATH, mockHttpClient);
|
||||
|
||||
// Execute
|
||||
Optional<JsonNode> 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");
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user