Skip to content

Commit 8505951

Browse files
authored
feat: use subdomain encoded in SDK token (#102)
* feat: enhanced SDK token for encoding customer-specific service gateway subdomains
1 parent 5283c5c commit 8505951

File tree

6 files changed

+335
-11
lines changed

6 files changed

+335
-11
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package cloud.eppo;
2+
3+
import static cloud.eppo.Constants.DEFAULT_BASE_URL;
4+
5+
import org.jetbrains.annotations.NotNull;
6+
import org.jetbrains.annotations.Nullable;
7+
8+
/**
9+
* Utility class for constructing Eppo API base URL. Determines the effective base URL considering
10+
* baseUrl, subdomain from SDK token, and defaultUrl in that order.
11+
*/
12+
public class ApiEndpoints {
13+
14+
private final SDKKey sdkKey;
15+
private final String baseUrl;
16+
17+
/**
18+
* Creates a new ApiEndpoints instance.
19+
*
20+
* @param sdkKey SDK Key instance for subdomain
21+
* @param baseUrl Custom base URL (optional)
22+
*/
23+
public ApiEndpoints(@NotNull SDKKey sdkKey, @Nullable String baseUrl) {
24+
this.sdkKey = sdkKey;
25+
this.baseUrl = baseUrl;
26+
}
27+
28+
/**
29+
* Gets the normalized base URL based on the following priority: 1. If baseUrl is provided and not
30+
* equal to DEFAULT_BASE_URL, use it 2. If the SDK Key contains a subdomain, use it with
31+
* DEFAULT_BASE_URL 3. Otherwise, fall back to DEFAULT_BASE_URL
32+
*
33+
* <p>The returned URL will: - Always have a protocol (defaults to https:// if none provided) -
34+
* Never end with a trailing slash
35+
*
36+
* @return The normalized base URL
37+
*/
38+
public String getBaseUrl() {
39+
String effectiveUrl;
40+
41+
if (baseUrl != null && !baseUrl.equals(DEFAULT_BASE_URL)) {
42+
// This is to prevent forcing the SDK to send requests to the CDN server without a subdomain
43+
// even when encoded in
44+
// SDK Key.
45+
effectiveUrl = baseUrl;
46+
} else if (sdkKey.isValid()) {
47+
String subdomain = sdkKey.getSubdomain();
48+
if (subdomain != null) {
49+
String domainPart = DEFAULT_BASE_URL.replaceAll("^(https?:)?//", "");
50+
effectiveUrl = subdomain + "." + domainPart;
51+
} else {
52+
effectiveUrl = DEFAULT_BASE_URL;
53+
}
54+
} else {
55+
effectiveUrl = DEFAULT_BASE_URL;
56+
}
57+
58+
// Remove any trailing slashes
59+
effectiveUrl = effectiveUrl.replaceAll("/+$", "");
60+
61+
// Add protocol if missing
62+
if (!effectiveUrl.matches("^(https?://|//).*")) {
63+
effectiveUrl = "https://" + effectiveUrl;
64+
}
65+
66+
return effectiveUrl;
67+
}
68+
}

src/main/java/cloud/eppo/BaseEppoClient.java

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,15 @@ protected BaseEppoClient(
7474
@Nullable IAssignmentCache assignmentCache,
7575
@Nullable IAssignmentCache banditAssignmentCache) {
7676

77-
if (apiKey == null) {
78-
throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key");
79-
}
80-
if (sdkName == null || sdkVersion == null) {
81-
throw new IllegalArgumentException(
82-
"Unable to initialize Eppo SDK due to missing SDK name or version");
83-
}
8477
if (apiBaseUrl == null) {
8578
apiBaseUrl = host != null ? Constants.appendApiPathToHost(host) : Constants.DEFAULT_BASE_URL;
8679
}
8780

8881
this.assignmentCache = assignmentCache;
8982
this.banditAssignmentCache = banditAssignmentCache;
9083

91-
EppoHttpClient httpClient = buildHttpClient(apiBaseUrl, apiKey, sdkName, sdkVersion);
84+
EppoHttpClient httpClient =
85+
buildHttpClient(apiBaseUrl, new SDKKey(apiKey), sdkName, sdkVersion);
9286
this.configurationStore =
9387
configurationStore != null ? configurationStore : new ConfigurationStore();
9488

@@ -110,10 +104,12 @@ protected BaseEppoClient(
110104
}
111105

112106
private EppoHttpClient buildHttpClient(
113-
String apiBaseUrl, String apiKey, String sdkName, String sdkVersion) {
107+
String apiBaseUrl, SDKKey sdkKey, String sdkName, String sdkVersion) {
108+
ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl);
109+
114110
return httpClientOverride != null
115111
? httpClientOverride
116-
: new EppoHttpClient(apiBaseUrl, apiKey, sdkName, sdkVersion);
112+
: new EppoHttpClient(endpointHelper.getBaseUrl(), sdkKey.getToken(), sdkName, sdkVersion);
117113
}
118114

119115
protected void loadConfiguration() {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cloud.eppo;
2+
3+
import java.net.URLDecoder;
4+
import java.nio.charset.StandardCharsets;
5+
import java.util.Collections;
6+
import java.util.HashMap;
7+
import java.util.Map;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
11+
/**
12+
* Wrapper for an SDK key; built from the SDK Key token string, this class extracts encoded fields,
13+
* such as the customer-specific service gateway subdomain
14+
*/
15+
public class SDKKey {
16+
private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class);
17+
18+
private final String sdkTokenString;
19+
private final Map<String, String> decodedParams;
20+
21+
/** @param sdkToken The "SDK Key" string provided by the user. */
22+
public SDKKey(String sdkToken) {
23+
this.sdkTokenString = sdkToken;
24+
this.decodedParams = decodeToken(sdkToken);
25+
}
26+
27+
private Map<String, String> decodeToken(String token) {
28+
try {
29+
String[] parts = token.split("\\.");
30+
if (parts.length < 2) {
31+
return Collections.emptyMap();
32+
}
33+
34+
String payload = parts[1];
35+
if (payload == null) {
36+
return Collections.emptyMap();
37+
}
38+
39+
String decodedString = Utils.base64Decode(payload);
40+
final Map<String, String> query_pairs = new HashMap<>();
41+
final String[] pairs = decodedString.split("&");
42+
43+
for (String pair : pairs) {
44+
if (pair.isEmpty()) {
45+
continue;
46+
}
47+
final String[] pairParts = pair.split("=");
48+
final String key = URLDecoder.decode(pairParts[0], StandardCharsets.UTF_8.name());
49+
final String value =
50+
pairParts.length > 1
51+
? URLDecoder.decode(pairParts[1], StandardCharsets.UTF_8.name())
52+
: null;
53+
54+
query_pairs.put(key, value);
55+
}
56+
return query_pairs;
57+
58+
} catch (Exception e) {
59+
log.error("[Eppo SDK] error parsing SDK Key {}", token, e);
60+
return Collections.emptyMap();
61+
}
62+
}
63+
64+
/**
65+
* Gets the subdomain from the decoded token.
66+
*
67+
* @return The subdomain or null if not present
68+
*/
69+
public String getSubdomain() {
70+
return decodedParams.get("cs");
71+
}
72+
73+
/** Gets the full SDK Key token string. */
74+
public String getToken() {
75+
return sdkTokenString;
76+
}
77+
78+
/**
79+
* Checks if the SDK Key had the subdomain encoded.
80+
*
81+
* @return true if the token is valid and contains required parameters
82+
*/
83+
public boolean isValid() {
84+
return !decodedParams.isEmpty();
85+
}
86+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package cloud.eppo;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
public class ApiEndpointsTest {
8+
final SDKKey plainKey = new SDKKey("flat token");
9+
10+
@Test
11+
public void testDefaultBaseUrl() {
12+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, null);
13+
assertEquals("https://fscdn.eppo.cloud/api", endpoints.getBaseUrl());
14+
}
15+
16+
@Test
17+
public void testCustomBaseUrl() {
18+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, "custom.domain/api");
19+
assertEquals("https://custom.domain/api", endpoints.getBaseUrl());
20+
}
21+
22+
@Test
23+
public void testCustomBaseUrlWithProtocol() {
24+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, "http://custom.domain/api");
25+
assertEquals("http://custom.domain/api", endpoints.getBaseUrl());
26+
}
27+
28+
@Test
29+
public void testCustomBaseUrlWithPort() {
30+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, "http://custom.domain/api:1337");
31+
assertEquals("http://custom.domain/api:1337", endpoints.getBaseUrl());
32+
}
33+
34+
@Test
35+
public void testCustomBaseUrlWithProtocolRelative() {
36+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, "//custom.domain/api");
37+
assertEquals("//custom.domain/api", endpoints.getBaseUrl());
38+
}
39+
40+
@Test
41+
public void testCustomBaseUrlWithTrailingSlash() {
42+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, "custom.domain/api/");
43+
assertEquals("https://custom.domain/api", endpoints.getBaseUrl());
44+
}
45+
46+
@Test
47+
public void testSubdomainFromToken() {
48+
String payload = "cs=test-subdomain";
49+
String encodedPayload = Utils.base64Encode(payload);
50+
String token = "signature." + encodedPayload;
51+
52+
SDKKey sdkKey = new SDKKey(token);
53+
ApiEndpoints endpoints = new ApiEndpoints(sdkKey, null);
54+
55+
assertEquals("https://test-subdomain.fscdn.eppo.cloud/api", endpoints.getBaseUrl());
56+
}
57+
58+
@Test
59+
public void testCustomBaseUrlTakesPrecedenceOverSubdomain() {
60+
String payload = "cs=test-subdomain";
61+
String encodedPayload = Utils.base64Encode(payload);
62+
String token = "signature." + encodedPayload;
63+
64+
SDKKey decoder = new SDKKey(token);
65+
ApiEndpoints endpoints = new ApiEndpoints(decoder, "custom.domain/api");
66+
67+
assertEquals("https://custom.domain/api", endpoints.getBaseUrl());
68+
}
69+
70+
@Test
71+
public void testMultipleTrailingSlashes() {
72+
ApiEndpoints endpoints = new ApiEndpoints(plainKey, "custom.domain/api////");
73+
assertEquals("https://custom.domain/api", endpoints.getBaseUrl());
74+
}
75+
76+
@Test
77+
public void testInvalidToken() {
78+
SDKKey sdkKey = new SDKKey("invalid-token");
79+
ApiEndpoints endpoints = new ApiEndpoints(sdkKey, null);
80+
81+
assertEquals("https://fscdn.eppo.cloud/api", endpoints.getBaseUrl());
82+
}
83+
}

src/test/java/cloud/eppo/BaseEppoClientTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ public void testPolling() {
649649
// True until the next config is fetched.
650650
assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false));
651651

652-
sleepUninterruptedly(25);
652+
sleepUninterruptedly(50);
653653

654654
assertFalse(eppoClient.getBooleanAssignment("bool_flag", "subject1", false));
655655

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package cloud.eppo;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
public class SDKKeyTest {
8+
9+
@Test
10+
public void testValidToken() {
11+
// Create a valid token with encoded subdomain
12+
String payload = "cs=test-subdomain";
13+
String encodedPayload = Utils.base64Encode(payload);
14+
String token = "signature." + encodedPayload;
15+
16+
SDKKey sdkKey = new SDKKey(token);
17+
18+
assertTrue(sdkKey.isValid());
19+
assertEquals("test-subdomain", sdkKey.getSubdomain());
20+
assertEquals(token, sdkKey.getToken());
21+
}
22+
23+
@Test
24+
public void testInvalidToken() {
25+
SDKKey sdkKey = new SDKKey("invalid-token");
26+
27+
assertFalse(sdkKey.isValid());
28+
assertNull(sdkKey.getSubdomain());
29+
assertEquals("invalid-token", sdkKey.getToken());
30+
}
31+
32+
@Test
33+
public void testEmptyToken() {
34+
SDKKey sdkKey = new SDKKey("");
35+
36+
assertFalse(sdkKey.isValid());
37+
assertNull(sdkKey.getSubdomain());
38+
assertEquals("", sdkKey.getToken());
39+
}
40+
41+
@Test
42+
public void testTokenWithoutSubdomain() {
43+
String payload = "other=value";
44+
String encodedPayload = Utils.base64Encode(payload);
45+
String token = "signature." + encodedPayload;
46+
47+
SDKKey sdkKey = new SDKKey(token);
48+
49+
// Key is valid with any encoded data.
50+
assertTrue(sdkKey.isValid());
51+
assertNull(sdkKey.getSubdomain());
52+
assertEquals(token, sdkKey.getToken());
53+
}
54+
55+
@Test
56+
public void testTokenWithMultipleParams() {
57+
String payload = "cs=test-subdomain&other=value";
58+
String encodedPayload = Utils.base64Encode(payload);
59+
String token = "signature." + encodedPayload;
60+
61+
SDKKey sdkKey = new SDKKey(token);
62+
63+
assertTrue(sdkKey.isValid());
64+
assertEquals("test-subdomain", sdkKey.getSubdomain());
65+
assertEquals(token, sdkKey.getToken());
66+
}
67+
68+
@Test
69+
public void testTokenWithEncodedCharacters() {
70+
String payload = "cs=test%20subdomain&other=special%26value";
71+
String encodedPayload = Utils.base64Encode(payload);
72+
String token = "signature." + encodedPayload;
73+
74+
SDKKey sdkKey = new SDKKey(token);
75+
76+
assertTrue(sdkKey.isValid());
77+
assertEquals("test subdomain", sdkKey.getSubdomain());
78+
assertEquals(token, sdkKey.getToken());
79+
}
80+
81+
@Test
82+
public void testTokenWithMalformedBase64() {
83+
String token = "signature.not-valid-base64";
84+
85+
SDKKey sdkKey = new SDKKey(token);
86+
87+
assertFalse(sdkKey.isValid());
88+
assertNull(sdkKey.getSubdomain());
89+
assertEquals(token, sdkKey.getToken());
90+
}
91+
}

0 commit comments

Comments
 (0)