feat(oss): add clientId to ctaLink (#15071)

This commit is contained in:
Ben Blazke 2025-10-23 15:41:52 -07:00 committed by GitHub
parent eed191c72e
commit f5d0efcc73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 351 additions and 5 deletions

View File

@ -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(

View File

@ -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<JsonNode> 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<JsonNode> 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.
*
* <p>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;
}
}
}

View File

@ -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.
*
* <p>Supports an optional {@code refreshCache} argument to clear the cache before fetching.
*
* <p>Decorates the CTA link with the instance's client ID.
*/
@Slf4j
public class ProductUpdateResolver implements DataFetcher<CompletableFuture<ProductUpdate>> {
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<ProductUpdate> 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<CompletableFuture<Prod
_productUpdateService.clearCache();
}
String clientId = null;
try {
clientId = getClientId(context);
if (clientId != null) {
log.debug("Retrieved client ID for product update decoration: {}", clientId);
}
} catch (Exception e) {
log.warn(
"Failed to retrieve client ID, product update link will not be decorated: {}",
e.getMessage());
log.debug("Client ID retrieval error details", e);
}
ProductUpdate productUpdate =
ProductUpdateParser.parseProductUpdate(
_productUpdateService.getLatestProductUpdate());
_productUpdateService.getLatestProductUpdate(), clientId);
if (productUpdate != null) {
log.debug(
@ -72,4 +97,21 @@ public class ProductUpdateResolver implements DataFetcher<CompletableFuture<Prod
}
});
}
private String getClientId(@Nonnull final QueryContext context) {
try {
RecordTemplate clientIdAspect =
_entityService.getLatestAspect(
context.getOperationContext(),
UrnUtils.getUrn(Constants.CLIENT_ID_URN),
Constants.CLIENT_ID_ASPECT);
if (clientIdAspect instanceof TelemetryClientId) {
return ((TelemetryClientId) clientIdAspect).getClientId();
}
} catch (Exception e) {
log.debug("Error retrieving client ID: {}", e.getMessage());
}
return null;
}
}

View File

@ -383,4 +383,194 @@ public class ProductUpdateParserTest {
assertNotNull(result.getDescription());
assertEquals(result.getDescription().length(), 10000);
}
@Test
public void testParseProductUpdateWithClientId() throws Exception {
String jsonString =
"{"
+ "\"enabled\": true,"
+ "\"id\": \"v1.0.0\","
+ "\"title\": \"What's New\","
+ "\"ctaLink\": \"https://example.com\""
+ "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "abc-123-def-456";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertEquals(result.getCtaLink(), "https://example.com?q=abc-123-def-456");
}
@Test
public void testParseProductUpdateWithClientIdAndExistingQueryParams() throws Exception {
String jsonString =
"{"
+ "\"enabled\": true,"
+ "\"id\": \"v1.0.0\","
+ "\"title\": \"What's New\","
+ "\"ctaLink\": \"https://example.com?foo=bar\""
+ "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "abc-123-def-456";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertEquals(result.getCtaLink(), "https://example.com?foo=bar&q=abc-123-def-456");
}
@Test
public void testParseProductUpdateWithClientIdMultipleQueryParams() throws Exception {
String jsonString =
"{"
+ "\"enabled\": true,"
+ "\"id\": \"v1.0.0\","
+ "\"title\": \"What's New\","
+ "\"ctaLink\": \"https://example.com?foo=bar&baz=qux#anchor\""
+ "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "test-uuid";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertEquals(result.getCtaLink(), "https://example.com?foo=bar&baz=qux#anchor&q=test-uuid");
}
@Test
public void testParseProductUpdateWithNullClientId() 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), null);
assertNotNull(result);
assertEquals(result.getCtaLink(), "https://example.com");
}
@Test
public void testParseProductUpdateWithEmptyClientId() 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);
assertEquals(result.getCtaLink(), "https://example.com");
}
@Test
public void testParseProductUpdateWithWhitespaceClientId() 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);
assertEquals(result.getCtaLink(), "https://example.com");
}
@Test
public void testParseProductUpdateWithClientIdAndEmptyCtaLink() throws Exception {
String jsonString =
"{"
+ "\"enabled\": true,"
+ "\"id\": \"v1.0.0\","
+ "\"title\": \"What's New\","
+ "\"ctaLink\": \"\""
+ "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "abc-123";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertEquals(result.getCtaLink(), "");
}
@Test
public void testParseProductUpdateWithClientIdAndNoCtaLink() throws Exception {
String jsonString =
"{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "abc-123";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertEquals(result.getCtaLink(), "");
}
@Test
public void testParseProductUpdateWithClientIdSpecialCharacters() throws Exception {
String jsonString =
"{"
+ "\"enabled\": true,"
+ "\"id\": \"v1.0.0\","
+ "\"title\": \"What's New\","
+ "\"ctaLink\": \"https://example.com\""
+ "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "abc 123+def/456";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertEquals(result.getCtaLink(), "https://example.com?q=abc+123%2Bdef%2F456");
}
@Test
public void testParseProductUpdateWithClientIdUnicodeCharacters() throws Exception {
String jsonString =
"{"
+ "\"enabled\": true,"
+ "\"id\": \"v1.0.0\","
+ "\"title\": \"What's New\","
+ "\"ctaLink\": \"https://example.com\""
+ "}";
JsonNode jsonNode = objectMapper.readTree(jsonString);
String clientId = "测试-client-id-🎉";
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
assertNotNull(result);
assertTrue(result.getCtaLink().startsWith("https://example.com?q="));
assertTrue(result.getCtaLink().contains("%"));
}
@Test
public void testParseProductUpdateBackwardCompatibilityWithoutClientId() 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);
assertEquals(result.getCtaLink(), "https://example.com");
}
}

View File

@ -5,10 +5,16 @@ import static org.testng.Assert.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.linkedin.common.urn.UrnUtils;
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.DataFetchingEnvironment;
import io.datahubproject.metadata.context.OperationContext;
import java.util.Optional;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@ -19,7 +25,10 @@ public class ProductUpdateResolverTest {
@Mock private ProductUpdateService mockProductUpdateService;
@Mock private FeatureFlags mockFeatureFlags;
@Mock private EntityService<?> 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");
}
}