mirror of
https://github.com/datahub-project/datahub.git
synced 2025-10-25 16:05:11 +00:00
feat(oss): add clientId to ctaLink (#15071)
This commit is contained in:
parent
eed191c72e
commit
f5d0efcc73
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user