Skip to content

Commit f5d0efc

Browse files
authored
feat(oss): add clientId to ctaLink (#15071)
1 parent eed191c commit f5d0efc

File tree

5 files changed

+351
-5
lines changed

5 files changed

+351
-5
lines changed

datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1007,7 +1007,8 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) {
10071007
this.s3Util != null))
10081008
.dataFetcher(
10091009
"latestProductUpdate",
1010-
new ProductUpdateResolver(this.productUpdateService, this.featureFlags))
1010+
new ProductUpdateResolver(
1011+
this.productUpdateService, this.featureFlags, this.entityService))
10111012
.dataFetcher("me", new MeResolver(this.entityClient, featureFlags))
10121013
.dataFetcher("search", new SearchResolver(this.entityClient))
10131014
.dataFetcher(

datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import com.fasterxml.jackson.databind.JsonNode;
44
import com.linkedin.datahub.graphql.generated.ProductUpdate;
5+
import java.io.UnsupportedEncodingException;
6+
import java.net.URLEncoder;
7+
import java.nio.charset.StandardCharsets;
58
import java.util.Optional;
69
import javax.annotation.Nonnull;
710
import javax.annotation.Nullable;
@@ -20,13 +23,26 @@ private ProductUpdateParser() {
2023
}
2124

2225
/**
23-
* Parse JSON into a ProductUpdate object.
26+
* Parse JSON into a ProductUpdate object without clientId decoration.
2427
*
2528
* @param jsonOpt Optional JSON node containing product update data
2629
* @return ProductUpdate object if parsing succeeds and update is enabled, null otherwise
2730
*/
2831
@Nullable
2932
public static ProductUpdate parseProductUpdate(@Nonnull Optional<JsonNode> jsonOpt) {
33+
return parseProductUpdate(jsonOpt, null);
34+
}
35+
36+
/**
37+
* Parse JSON into a ProductUpdate object, decorating the ctaLink with clientId if provided.
38+
*
39+
* @param jsonOpt Optional JSON node containing product update data
40+
* @param clientId Optional client ID to append to ctaLink as a query parameter
41+
* @return ProductUpdate object if parsing succeeds and update is enabled, null otherwise
42+
*/
43+
@Nullable
44+
public static ProductUpdate parseProductUpdate(
45+
@Nonnull Optional<JsonNode> jsonOpt, @Nullable String clientId) {
3046
if (jsonOpt.isEmpty()) {
3147
log.debug("No product update JSON available");
3248
return null;
@@ -51,6 +67,11 @@ public static ProductUpdate parseProductUpdate(@Nonnull Optional<JsonNode> jsonO
5167
String ctaText = json.has("ctaText") ? json.get("ctaText").asText() : "Learn more";
5268
String ctaLink = json.has("ctaLink") ? json.get("ctaLink").asText() : "";
5369

70+
// Decorate ctaLink with clientId if provided
71+
if (clientId != null && !clientId.trim().isEmpty() && !ctaLink.isEmpty()) {
72+
ctaLink = decorateUrlWithClientId(ctaLink, clientId);
73+
}
74+
5475
// Build the ProductUpdate response
5576
ProductUpdate productUpdate = new ProductUpdate();
5677
productUpdate.setEnabled(enabled);
@@ -69,4 +90,26 @@ public static ProductUpdate parseProductUpdate(@Nonnull Optional<JsonNode> jsonO
6990

7091
return productUpdate;
7192
}
93+
94+
/**
95+
* Decorates a URL with a clientId query parameter.
96+
*
97+
* <p>Adds "?q={clientId}" if the URL has no query parameters, or "&q={clientId}" if it already
98+
* has query parameters.
99+
*
100+
* @param url The URL to decorate
101+
* @param clientId The client ID to append
102+
* @return The decorated URL
103+
*/
104+
@Nonnull
105+
private static String decorateUrlWithClientId(@Nonnull String url, @Nonnull String clientId) {
106+
try {
107+
String encodedClientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.toString());
108+
String separator = url.contains("?") ? "&" : "?";
109+
return url + separator + "q=" + encodedClientId;
110+
} catch (UnsupportedEncodingException e) {
111+
log.warn("Failed to URL-encode clientId, using original URL: {}", e.getMessage());
112+
return url;
113+
}
114+
}
72115
}

datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package com.linkedin.datahub.graphql.resolvers.config;
22

3+
import com.linkedin.common.urn.UrnUtils;
4+
import com.linkedin.data.template.RecordTemplate;
5+
import com.linkedin.datahub.graphql.QueryContext;
36
import com.linkedin.datahub.graphql.featureflags.FeatureFlags;
47
import com.linkedin.datahub.graphql.generated.ProductUpdate;
8+
import com.linkedin.metadata.Constants;
9+
import com.linkedin.metadata.entity.EntityService;
510
import com.linkedin.metadata.service.ProductUpdateService;
11+
import com.linkedin.telemetry.TelemetryClientId;
612
import graphql.schema.DataFetcher;
713
import graphql.schema.DataFetchingEnvironment;
814
import java.util.concurrent.CompletableFuture;
@@ -17,22 +23,28 @@
1723
* disabled.
1824
*
1925
* <p>Supports an optional {@code refreshCache} argument to clear the cache before fetching.
26+
*
27+
* <p>Decorates the CTA link with the instance's client ID.
2028
*/
2129
@Slf4j
2230
public class ProductUpdateResolver implements DataFetcher<CompletableFuture<ProductUpdate>> {
2331

2432
private final ProductUpdateService _productUpdateService;
2533
private final FeatureFlags _featureFlags;
34+
private final EntityService<?> _entityService;
2635

2736
public ProductUpdateResolver(
2837
@Nonnull final ProductUpdateService productUpdateService,
29-
@Nonnull final FeatureFlags featureFlags) {
38+
@Nonnull final FeatureFlags featureFlags,
39+
@Nonnull final EntityService<?> entityService) {
3040
this._productUpdateService = productUpdateService;
3141
this._featureFlags = featureFlags;
42+
this._entityService = entityService;
3243
}
3344

3445
@Override
3546
public CompletableFuture<ProductUpdate> get(DataFetchingEnvironment environment) {
47+
final QueryContext context = environment.getContext();
3648
final Boolean refreshCache = environment.getArgument("refreshCache");
3749
final boolean shouldRefresh = refreshCache != null && refreshCache;
3850

@@ -49,9 +61,22 @@ public CompletableFuture<ProductUpdate> get(DataFetchingEnvironment environment)
4961
_productUpdateService.clearCache();
5062
}
5163

64+
String clientId = null;
65+
try {
66+
clientId = getClientId(context);
67+
if (clientId != null) {
68+
log.debug("Retrieved client ID for product update decoration: {}", clientId);
69+
}
70+
} catch (Exception e) {
71+
log.warn(
72+
"Failed to retrieve client ID, product update link will not be decorated: {}",
73+
e.getMessage());
74+
log.debug("Client ID retrieval error details", e);
75+
}
76+
5277
ProductUpdate productUpdate =
5378
ProductUpdateParser.parseProductUpdate(
54-
_productUpdateService.getLatestProductUpdate());
79+
_productUpdateService.getLatestProductUpdate(), clientId);
5580

5681
if (productUpdate != null) {
5782
log.debug(
@@ -72,4 +97,21 @@ public CompletableFuture<ProductUpdate> get(DataFetchingEnvironment environment)
7297
}
7398
});
7499
}
100+
101+
private String getClientId(@Nonnull final QueryContext context) {
102+
try {
103+
RecordTemplate clientIdAspect =
104+
_entityService.getLatestAspect(
105+
context.getOperationContext(),
106+
UrnUtils.getUrn(Constants.CLIENT_ID_URN),
107+
Constants.CLIENT_ID_ASPECT);
108+
109+
if (clientIdAspect instanceof TelemetryClientId) {
110+
return ((TelemetryClientId) clientIdAspect).getClientId();
111+
}
112+
} catch (Exception e) {
113+
log.debug("Error retrieving client ID: {}", e.getMessage());
114+
}
115+
return null;
116+
}
75117
}

datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,194 @@ public void testParseProductUpdateLongStrings() throws Exception {
383383
assertNotNull(result.getDescription());
384384
assertEquals(result.getDescription().length(), 10000);
385385
}
386+
387+
@Test
388+
public void testParseProductUpdateWithClientId() throws Exception {
389+
String jsonString =
390+
"{"
391+
+ "\"enabled\": true,"
392+
+ "\"id\": \"v1.0.0\","
393+
+ "\"title\": \"What's New\","
394+
+ "\"ctaLink\": \"https://example.com\""
395+
+ "}";
396+
JsonNode jsonNode = objectMapper.readTree(jsonString);
397+
String clientId = "abc-123-def-456";
398+
399+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
400+
401+
assertNotNull(result);
402+
assertEquals(result.getCtaLink(), "https://example.com?q=abc-123-def-456");
403+
}
404+
405+
@Test
406+
public void testParseProductUpdateWithClientIdAndExistingQueryParams() throws Exception {
407+
String jsonString =
408+
"{"
409+
+ "\"enabled\": true,"
410+
+ "\"id\": \"v1.0.0\","
411+
+ "\"title\": \"What's New\","
412+
+ "\"ctaLink\": \"https://example.com?foo=bar\""
413+
+ "}";
414+
JsonNode jsonNode = objectMapper.readTree(jsonString);
415+
String clientId = "abc-123-def-456";
416+
417+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
418+
419+
assertNotNull(result);
420+
assertEquals(result.getCtaLink(), "https://example.com?foo=bar&q=abc-123-def-456");
421+
}
422+
423+
@Test
424+
public void testParseProductUpdateWithClientIdMultipleQueryParams() throws Exception {
425+
String jsonString =
426+
"{"
427+
+ "\"enabled\": true,"
428+
+ "\"id\": \"v1.0.0\","
429+
+ "\"title\": \"What's New\","
430+
+ "\"ctaLink\": \"https://example.com?foo=bar&baz=qux#anchor\""
431+
+ "}";
432+
JsonNode jsonNode = objectMapper.readTree(jsonString);
433+
String clientId = "test-uuid";
434+
435+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
436+
437+
assertNotNull(result);
438+
assertEquals(result.getCtaLink(), "https://example.com?foo=bar&baz=qux#anchor&q=test-uuid");
439+
}
440+
441+
@Test
442+
public void testParseProductUpdateWithNullClientId() throws Exception {
443+
String jsonString =
444+
"{"
445+
+ "\"enabled\": true,"
446+
+ "\"id\": \"v1.0.0\","
447+
+ "\"title\": \"What's New\","
448+
+ "\"ctaLink\": \"https://example.com\""
449+
+ "}";
450+
JsonNode jsonNode = objectMapper.readTree(jsonString);
451+
452+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), null);
453+
454+
assertNotNull(result);
455+
assertEquals(result.getCtaLink(), "https://example.com");
456+
}
457+
458+
@Test
459+
public void testParseProductUpdateWithEmptyClientId() throws Exception {
460+
String jsonString =
461+
"{"
462+
+ "\"enabled\": true,"
463+
+ "\"id\": \"v1.0.0\","
464+
+ "\"title\": \"What's New\","
465+
+ "\"ctaLink\": \"https://example.com\""
466+
+ "}";
467+
JsonNode jsonNode = objectMapper.readTree(jsonString);
468+
469+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), "");
470+
471+
assertNotNull(result);
472+
assertEquals(result.getCtaLink(), "https://example.com");
473+
}
474+
475+
@Test
476+
public void testParseProductUpdateWithWhitespaceClientId() throws Exception {
477+
String jsonString =
478+
"{"
479+
+ "\"enabled\": true,"
480+
+ "\"id\": \"v1.0.0\","
481+
+ "\"title\": \"What's New\","
482+
+ "\"ctaLink\": \"https://example.com\""
483+
+ "}";
484+
JsonNode jsonNode = objectMapper.readTree(jsonString);
485+
486+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), " ");
487+
488+
assertNotNull(result);
489+
assertEquals(result.getCtaLink(), "https://example.com");
490+
}
491+
492+
@Test
493+
public void testParseProductUpdateWithClientIdAndEmptyCtaLink() throws Exception {
494+
String jsonString =
495+
"{"
496+
+ "\"enabled\": true,"
497+
+ "\"id\": \"v1.0.0\","
498+
+ "\"title\": \"What's New\","
499+
+ "\"ctaLink\": \"\""
500+
+ "}";
501+
JsonNode jsonNode = objectMapper.readTree(jsonString);
502+
String clientId = "abc-123";
503+
504+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
505+
506+
assertNotNull(result);
507+
assertEquals(result.getCtaLink(), "");
508+
}
509+
510+
@Test
511+
public void testParseProductUpdateWithClientIdAndNoCtaLink() throws Exception {
512+
String jsonString =
513+
"{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}";
514+
JsonNode jsonNode = objectMapper.readTree(jsonString);
515+
String clientId = "abc-123";
516+
517+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
518+
519+
assertNotNull(result);
520+
assertEquals(result.getCtaLink(), "");
521+
}
522+
523+
@Test
524+
public void testParseProductUpdateWithClientIdSpecialCharacters() throws Exception {
525+
String jsonString =
526+
"{"
527+
+ "\"enabled\": true,"
528+
+ "\"id\": \"v1.0.0\","
529+
+ "\"title\": \"What's New\","
530+
+ "\"ctaLink\": \"https://example.com\""
531+
+ "}";
532+
JsonNode jsonNode = objectMapper.readTree(jsonString);
533+
String clientId = "abc 123+def/456";
534+
535+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
536+
537+
assertNotNull(result);
538+
assertEquals(result.getCtaLink(), "https://example.com?q=abc+123%2Bdef%2F456");
539+
}
540+
541+
@Test
542+
public void testParseProductUpdateWithClientIdUnicodeCharacters() throws Exception {
543+
String jsonString =
544+
"{"
545+
+ "\"enabled\": true,"
546+
+ "\"id\": \"v1.0.0\","
547+
+ "\"title\": \"What's New\","
548+
+ "\"ctaLink\": \"https://example.com\""
549+
+ "}";
550+
JsonNode jsonNode = objectMapper.readTree(jsonString);
551+
String clientId = "测试-client-id-🎉";
552+
553+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode), clientId);
554+
555+
assertNotNull(result);
556+
assertTrue(result.getCtaLink().startsWith("https://example.com?q="));
557+
assertTrue(result.getCtaLink().contains("%"));
558+
}
559+
560+
@Test
561+
public void testParseProductUpdateBackwardCompatibilityWithoutClientId() throws Exception {
562+
String jsonString =
563+
"{"
564+
+ "\"enabled\": true,"
565+
+ "\"id\": \"v1.0.0\","
566+
+ "\"title\": \"What's New\","
567+
+ "\"ctaLink\": \"https://example.com\""
568+
+ "}";
569+
JsonNode jsonNode = objectMapper.readTree(jsonString);
570+
571+
ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode));
572+
573+
assertNotNull(result);
574+
assertEquals(result.getCtaLink(), "https://example.com");
575+
}
386576
}

0 commit comments

Comments
 (0)