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 7901006666..dad7299379 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 @@ -1007,7 +1007,8 @@ public class GmsGraphQLEngine { this.s3Util != null)) .dataFetcher( "latestProductUpdate", - new ProductUpdateResolver(this.productUpdateService, this.featureFlags)) + new ProductUpdateResolver( + this.productUpdateService, this.featureFlags, this.entityService)) .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 index 3c36504400..3932f135e3 100644 --- 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 @@ -2,6 +2,9 @@ package com.linkedin.datahub.graphql.resolvers.config; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.datahub.graphql.generated.ProductUpdate; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -20,13 +23,26 @@ public class ProductUpdateParser { } /** - * Parse JSON into a ProductUpdate object. + * Parse JSON into a ProductUpdate object without clientId decoration. * * @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) { + return parseProductUpdate(jsonOpt, null); + } + + /** + * Parse JSON into a ProductUpdate object, decorating the ctaLink with clientId if provided. + * + * @param jsonOpt Optional JSON node containing product update data + * @param clientId Optional client ID to append to ctaLink as a query parameter + * @return ProductUpdate object if parsing succeeds and update is enabled, null otherwise + */ + @Nullable + public static ProductUpdate parseProductUpdate( + @Nonnull Optional jsonOpt, @Nullable String clientId) { if (jsonOpt.isEmpty()) { log.debug("No product update JSON available"); return null; @@ -51,6 +67,11 @@ public class ProductUpdateParser { String ctaText = json.has("ctaText") ? json.get("ctaText").asText() : "Learn more"; String ctaLink = json.has("ctaLink") ? json.get("ctaLink").asText() : ""; + // Decorate ctaLink with clientId if provided + if (clientId != null && !clientId.trim().isEmpty() && !ctaLink.isEmpty()) { + ctaLink = decorateUrlWithClientId(ctaLink, clientId); + } + // Build the ProductUpdate response ProductUpdate productUpdate = new ProductUpdate(); productUpdate.setEnabled(enabled); @@ -69,4 +90,26 @@ public class ProductUpdateParser { return productUpdate; } + + /** + * Decorates a URL with a clientId query parameter. + * + *

Adds "?q={clientId}" if the URL has no query parameters, or "&q={clientId}" if it already + * has query parameters. + * + * @param url The URL to decorate + * @param clientId The client ID to append + * @return The decorated URL + */ + @Nonnull + private static String decorateUrlWithClientId(@Nonnull String url, @Nonnull String clientId) { + try { + String encodedClientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.toString()); + String separator = url.contains("?") ? "&" : "?"; + return url + separator + "q=" + encodedClientId; + } catch (UnsupportedEncodingException e) { + log.warn("Failed to URL-encode clientId, using original URL: {}", e.getMessage()); + return url; + } + } } 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 index dbe2b545a2..75b674dbf4 100644 --- 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 @@ -1,8 +1,14 @@ package com.linkedin.datahub.graphql.resolvers.config; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.ProductUpdate; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.service.ProductUpdateService; +import com.linkedin.telemetry.TelemetryClientId; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.util.concurrent.CompletableFuture; @@ -17,22 +23,28 @@ import lombok.extern.slf4j.Slf4j; * disabled. * *

Supports an optional {@code refreshCache} argument to clear the cache before fetching. + * + *

Decorates the CTA link with the instance's client ID. */ @Slf4j public class ProductUpdateResolver implements DataFetcher> { private final ProductUpdateService _productUpdateService; private final FeatureFlags _featureFlags; + private final EntityService _entityService; public ProductUpdateResolver( @Nonnull final ProductUpdateService productUpdateService, - @Nonnull final FeatureFlags featureFlags) { + @Nonnull final FeatureFlags featureFlags, + @Nonnull final EntityService entityService) { this._productUpdateService = productUpdateService; this._featureFlags = featureFlags; + this._entityService = entityService; } @Override public CompletableFuture get(DataFetchingEnvironment environment) { + final QueryContext context = environment.getContext(); final Boolean refreshCache = environment.getArgument("refreshCache"); final boolean shouldRefresh = refreshCache != null && refreshCache; @@ -49,9 +61,22 @@ public class ProductUpdateResolver implements DataFetcher mockEntityService; @Mock private DataFetchingEnvironment mockDataFetchingEnvironment; + @Mock private QueryContext mockQueryContext; + @Mock private OperationContext mockOperationContext; private ProductUpdateResolver resolver; private ObjectMapper objectMapper; @@ -28,7 +37,10 @@ public class ProductUpdateResolverTest { public void setupTest() { MockitoAnnotations.openMocks(this); objectMapper = new ObjectMapper(); - resolver = new ProductUpdateResolver(mockProductUpdateService, mockFeatureFlags); + resolver = + new ProductUpdateResolver(mockProductUpdateService, mockFeatureFlags, mockEntityService); + when(mockDataFetchingEnvironment.getContext()).thenReturn(mockQueryContext); + when(mockQueryContext.getOperationContext()).thenReturn(mockOperationContext); } @Test @@ -357,4 +369,62 @@ public class ProductUpdateResolverTest { assertEquals(result.getCtaText(), ""); assertEquals(result.getCtaLink(), ""); } + + @Test + public void testGetProductUpdateWithClientIdDecoration() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"ctaLink\": \"https://example.com\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + TelemetryClientId clientIdAspect = new TelemetryClientId().setClientId("test-client-id-123"); + when(mockEntityService.getLatestAspect( + eq(mockOperationContext), + eq(UrnUtils.getUrn(Constants.CLIENT_ID_URN)), + eq(Constants.CLIENT_ID_ASPECT))) + .thenReturn(clientIdAspect); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNotNull(result); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getCtaLink(), "https://example.com?q=test-client-id-123"); + } + + @Test + public void testGetProductUpdateWithClientIdDecorationFailure() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"ctaLink\": \"https://example.com\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + when(mockEntityService.getLatestAspect(any(), any(), any())) + .thenThrow(new RuntimeException("Entity service error")); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify - should still return product update without clientId decoration + assertNotNull(result); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getCtaLink(), "https://example.com"); + } }