-
Notifications
You must be signed in to change notification settings - Fork 0
feat!: Send product metadata in custom header #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
97eae6c
ffb9d7c
5ac9633
3deb1ce
342bdba
759aafd
1092857
406f4c7
2b278c3
2c87c47
6ac3121
90102dc
ce2a7fb
281994a
8ed41d8
72fab12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,59 @@ | ||
| package com.octopus.openfeature.provider; | ||
|
|
||
| import com.fasterxml.jackson.core.type.TypeReference; | ||
|
|
||
| import java.io.IOException; | ||
| import java.net.MalformedURLException; | ||
| import java.net.URI; | ||
| import java.net.URISyntaxException; | ||
| import java.net.URL; | ||
| import java.net.http.HttpClient; | ||
| import java.net.http.HttpRequest; | ||
| import java.net.http.HttpResponse; | ||
| import java.util.Arrays; | ||
| import java.util.Base64; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import java.util.*; | ||
|
|
||
| class OctopusClient { | ||
|
|
||
| private final OctopusConfiguration config; | ||
| private static final System.Logger logger = System.getLogger(OctopusClient.class.getName()); | ||
| private static final int StatusCodeNotFound = 404; | ||
| private static final String PROVIDER_VERSION = loadProviderVersion(); | ||
|
|
||
| private static String loadProviderVersion() { | ||
| try { | ||
| var projectProperties = new Properties(); | ||
| try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: this |
||
| { | ||
| if(resourceStream == null) { | ||
| logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version."); | ||
| return null; | ||
| } | ||
|
|
||
| projectProperties.load(resourceStream); | ||
| } | ||
|
|
||
| return projectProperties.getProperty("version"); | ||
| } catch (IOException e) { | ||
| logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| OctopusClient(OctopusConfiguration config){ | ||
| OctopusClient(OctopusConfiguration config) { | ||
| this.config = config; | ||
| } | ||
|
|
||
| Boolean haveFeatureTogglesChanged(byte[] contentHash) | ||
| { | ||
| if (contentHash.length == 0) { return true; } | ||
| URI checkURI = getCheckURI(); | ||
|
|
||
| Boolean haveFeatureTogglesChanged(byte[] contentHash) { | ||
| if (contentHash.length == 0) { | ||
| return true; | ||
| } | ||
| URI checkURI = getCheckURI(); | ||
| HttpClient client = HttpClient.newHttpClient(); | ||
| HttpRequest request = HttpRequest.newBuilder() | ||
| .GET() | ||
| .uri(checkURI) | ||
| .header("Authorization", String.format("Bearer %s", config.getClientIdentifier())) | ||
| .header("X-Octopus-Client", buildOctopusClientHeaderValue()) | ||
| .build(); | ||
|
liamhughes marked this conversation as resolved.
|
||
| try { | ||
| HttpResponse<String> httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString()); | ||
|
|
@@ -43,53 +65,65 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash) | |
| return false; | ||
| } | ||
| } | ||
|
|
||
| FeatureToggles getFeatureToggleEvaluationManifest() | ||
| { | ||
|
|
||
| FeatureToggles getFeatureToggleEvaluationManifest() { | ||
| URI manifestURI = getManifestURI(); | ||
| HttpClient client = HttpClient.newHttpClient(); | ||
| HttpRequest request = HttpRequest.newBuilder() | ||
| .GET() | ||
| .uri(manifestURI) | ||
| .header("Authorization", String.format("Bearer %s", config.getClientIdentifier())) | ||
| .header("X-Octopus-Client", buildOctopusClientHeaderValue()) | ||
| .build(); | ||
| try { | ||
| HttpResponse<String> httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString()); | ||
| if (httpResponse.statusCode() == StatusCodeNotFound) { | ||
| logger.log(System.Logger.Level.WARNING,String.format("Failed to retrieve feature toggles for client identifier %s from %s", config.getClientIdentifier(), manifestURI.toString())); | ||
| return null; | ||
| logger.log(System.Logger.Level.WARNING, String.format("Failed to retrieve feature toggles for client identifier %s from %s", config.getClientIdentifier(), manifestURI.toString())); | ||
| return null; | ||
| } | ||
| Optional<String> contentHashHeader = httpResponse.headers().firstValue("ContentHash"); | ||
| if (contentHashHeader.isEmpty()) { | ||
| logger.log(System.Logger.Level.WARNING,String.format("Feature toggle response from %s did not contain expected ContentHash header", manifestURI.toString())); | ||
| logger.log(System.Logger.Level.WARNING, String.format("Feature toggle response from %s did not contain expected ContentHash header", manifestURI.toString())); | ||
| return null; | ||
| } | ||
| var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>(){}); | ||
| var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>() {}); | ||
| return new FeatureToggles(evaluations, Base64.getDecoder().decode(contentHashHeader.get())); | ||
| } catch (Exception e) { | ||
| logger.log(System.Logger.Level.WARNING, "Unable to query Octopus Feature Toggle service", e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| String buildOctopusClientHeaderValue() { | ||
| var clientHeaderValueBuilder = new StringBuilder(this.config.getProductMetadata().getName()); | ||
|
|
||
| this.config.getProductMetadata().getVersion().ifPresent(s -> clientHeaderValueBuilder.append("/").append(s)); | ||
|
|
||
| clientHeaderValueBuilder.append(" openfeature-provider-java/").append(PROVIDER_VERSION); | ||
|
|
||
| return clientHeaderValueBuilder.toString(); | ||
| } | ||
|
|
||
| private URI getCheckURI() { | ||
| try { | ||
| return new URL(config.getServerUri().toURL(), "/api/featuretoggles/check/v3/").toURI(); | ||
| } catch (MalformedURLException | URISyntaxException ignored) // we know this URL is well-formed | ||
| { } | ||
| { | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private URI getManifestURI() { | ||
| try { | ||
| return new URL(config.getServerUri().toURL(), "/api/toggles/evaluations/v3/").toURI(); | ||
| } catch (MalformedURLException | URISyntaxException ignored) // we know this URL is well-formed | ||
| { } | ||
| { | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // This class needs to be static to allow deserialization | ||
| private static class FeatureToggleCheckResponse { | ||
| public byte[] contentHash; | ||
| } | ||
| public byte[] contentHash; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
| import dev.openfeature.sdk.*; | ||
|
|
||
| public class OctopusProvider extends EventProvider { | ||
| private static final String PROVIDER_NAME = "octopus"; | ||
| private static final String PROVIDER_NAME = "octopus-java-provider"; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an aside, I am aligning these between the three libraries. https://openfeature.dev/specification/sections/providers/#requirement-211 |
||
| private final OctopusConfiguration config; | ||
| private final OctopusContextProvider contextProvider; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package com.octopus.openfeature.provider; | ||
|
|
||
| import java.util.Optional; | ||
| import java.util.regex.Pattern; | ||
|
|
||
| public class ProductMetadata { | ||
|
|
||
| // https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens | ||
| private static final Pattern UNSUPPORTED_CHARS = Pattern.compile("[^a-zA-Z0-9!#$%&'*+\\-.^_`|~]"); | ||
|
|
||
| private final String name; | ||
| private final String version; | ||
|
|
||
| public ProductMetadata(String name) { | ||
| this.name = clean(name); | ||
| this.version = null; | ||
|
|
||
| validateName(); | ||
| } | ||
|
|
||
| public ProductMetadata(String name, String version) { | ||
| this.name = clean(name); | ||
| this.version = clean(version); | ||
|
|
||
| validateName(); | ||
| validateVersion(); | ||
| } | ||
|
|
||
| public String getName() { | ||
| return name; | ||
| } | ||
|
|
||
| public Optional<String> getVersion() { | ||
| return Optional.ofNullable(version); | ||
| } | ||
|
|
||
| private String clean(String value) { | ||
| if (value == null || value.isBlank()) { | ||
| return null; | ||
| } | ||
|
|
||
| return UNSUPPORTED_CHARS.matcher(value).replaceAll(""); | ||
| } | ||
|
|
||
| private void validateName() { | ||
| if (name == null || name.isBlank()) { | ||
| throw new IllegalArgumentException("Product name must contain at least one valid token character."); | ||
| } | ||
| } | ||
|
|
||
| private void validateVersion() { | ||
| if (version == null || version.isBlank()) { | ||
| throw new IllegalArgumentException("Product version must contain at least one valid token character."); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| version=${project.version} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| package com.octopus.openfeature.provider; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.Properties; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class OctopusClientTests { | ||
|
|
||
| private static final String PROVIDER_VERSION = loadProviderVersion(); | ||
|
|
||
| private static String loadProviderVersion() { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thought from a background thread in my brain: perhaps I should just load this value directly from the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did a little research. Sounds like the path to |
||
| try { | ||
| var projectProperties = new Properties(); | ||
| try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")) | ||
| { | ||
|
liamhughes marked this conversation as resolved.
|
||
| projectProperties.load(resourceStream); | ||
| } | ||
|
|
||
| var version = projectProperties.getProperty("version"); | ||
| assertThat(version).matches("\\d+.*"); // Ensure property filtering is working. | ||
| return version; | ||
| } catch (IOException e) { | ||
| throw new RuntimeException("Could not load project.properties.", e); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void buildOctopusClientHeaderValue_withNameOnly_headerValueContainsProductNameAndProviderInformation() { | ||
| var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct")); | ||
| var client = new OctopusClient(config); | ||
|
|
||
| assertThat(client.buildOctopusClientHeaderValue()) | ||
| .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); | ||
| } | ||
|
|
||
| @Test | ||
| void buildOctopusClientHeaderValue_withNameAndVersion_headerValueContainsProductAndProviderInformation() { | ||
| var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct", "2024.1.0")); | ||
| var client = new OctopusClient(config); | ||
|
|
||
| assertThat(client.buildOctopusClientHeaderValue()) | ||
| .isEqualTo("MyProduct/2024.1.0 openfeature-provider-java/" + PROVIDER_VERSION); | ||
| } | ||
|
|
||
| @Test | ||
| void buildOctopusClientHeaderValue_withNameContainingUnsupportedChars_stripsCharsFromHeaderValue() { | ||
| // Note: More character checking tests are in ProductMetadataTests.java | ||
|
|
||
| var config = new OctopusConfiguration("test-id", new ProductMetadata("My Product")); | ||
| var client = new OctopusClient(config); | ||
|
|
||
| assertThat(client.buildOctopusClientHeaderValue()) | ||
| .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.