Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import dev.openfeature.sdk.EvaluationContext;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

/**
* Discriminator values for the polymorphic v4 client-side conditions. These mirror the values in the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.exceptions.ParseError;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

/**
* Reasons returned alongside a client-side evaluation. Both match the strings the Feature Flags service
* produces server-side, so a flag reads the same whichever side resolved it.
*
* <p>These are deliberately not OpenFeature's {@link dev.openfeature.sdk.Reason} values, which is a
* change in contract from v3: a caller that branched on {@code Reason.TARGETING_MATCH}, or that used the
* reason as a metrics dimension, will see these sentences instead — and the set of them now grows with
* the number of rule names.
*/
final class EvaluationReasons {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.octopus.openfeature.provider;

import java.util.List;

/**
* One response from the v4 evaluations endpoint: the server-side evaluation of every flag, and the
* content hash identifying that set.
*/
class EvaluationResponse {
private final List<ServerSideEvaluation> evaluations;
private final byte[] contentHash;

EvaluationResponse(List<ServerSideEvaluation> evaluations, byte[] contentHash) {
this.evaluations = evaluations;
this.contentHash = contentHash;
}

public List<ServerSideEvaluation> getEvaluations() {
return evaluations;
}

public byte[] getContentHash() {
return contentHash;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.List;
import java.util.Optional;

// TODO(BMBB-780): a v3 type, unused since the switch to v4.
class FeatureToggleEvaluation {
private final String slug;
private final boolean isEnabled;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.List;

// TODO(BMBB-780): a v3 type, unused since the switch to v4.
class FeatureToggles {
private final List<FeatureToggleEvaluation> evaluations;
private final byte[] contentHash;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.octopus.openfeature.provider.v4;
package com.octopus.openfeature.provider;

import java.util.ArrayList;
import java.util.Collections;
Expand Down
33 changes: 20 additions & 13 deletions src/main/java/com/octopus/openfeature/provider/OctopusClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ private static String loadProviderVersion() {
this.config = config;
}

Boolean haveFeatureTogglesChanged(byte[] contentHash) throws IOException, InterruptedException {
Boolean haveFeatureFlagsChanged(byte[] contentHash) throws IOException, InterruptedException {
if (contentHash.length == 0) {
return true;
}
Expand All @@ -56,31 +56,38 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash) throws IOException, Interr
.header("X-Octopus-Client", buildOctopusClientHeaderValue())
.build();
HttpResponse<String> httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString());
FeatureToggleCheckResponse checkResponse = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), FeatureToggleCheckResponse.class);
FeatureFlagCheckResponse checkResponse = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), FeatureFlagCheckResponse.class);
return !Arrays.equals(checkResponse.contentHash, contentHash);
}

FeatureToggles getFeatureToggleEvaluationManifest() throws IOException, InterruptedException {
URI manifestURI = getManifestURI();
EvaluationResponse getServerSideEvaluations() throws IOException, InterruptedException {
URI evaluationsURI = getEvaluationsURI();
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.GET()
.uri(manifestURI)
.uri(evaluationsURI)
.header("Authorization", String.format("Bearer %s", config.getClientIdentifier()))
.header("X-Octopus-Client", buildOctopusClientHeaderValue())
.build();
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()));
logger.log(System.Logger.Level.WARNING, String.format("Failed to retrieve feature flags for client identifier %s from %s", config.getClientIdentifier(), evaluationsURI.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 flag response from %s did not contain expected ContentHash header", evaluationsURI.toString()));
return null;
}
var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>() {});
return new FeatureToggles(evaluations, Base64.getDecoder().decode(contentHashHeader.get()));
var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<ServerSideEvaluation>>() {});
if (evaluations == null) {
// Returning null leaves the cache on its previous context, or on the empty one, both of
// which keep refetching. Storing a response with a usable content hash would not: the check
// endpoint would report no change and the provider would never recover.
logger.log(System.Logger.Level.WARNING, String.format("Feature flag response content from %s was empty", evaluationsURI.toString()));
return null;
}
return new EvaluationResponse(evaluations, Base64.getDecoder().decode(contentHashHeader.get()));
}

String buildOctopusClientHeaderValue() {
Expand All @@ -95,24 +102,24 @@ String buildOctopusClientHeaderValue() {

private URI getCheckURI() {
try {
return new URL(config.getServerUri().toURL(), "/api/featuretoggles/check/v3/").toURI();
return new URL(config.getServerUri().toURL(), "/api/feature-flags/check/v4/").toURI();
} catch (MalformedURLException | URISyntaxException ignored) // we know this URL is well-formed
{
}
return null;
}

private URI getManifestURI() {
private URI getEvaluationsURI() {
try {
return new URL(config.getServerUri().toURL(), "/api/toggles/evaluations/v3/").toURI();
return new URL(config.getServerUri().toURL(), "/api/feature-flags/evaluations/v4/").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 {
private static class FeatureFlagCheckResponse {
public byte[] contentHash;
}
}
155 changes: 52 additions & 103 deletions src/main/java/com/octopus/openfeature/provider/OctopusContext.java
Original file line number Diff line number Diff line change
@@ -1,133 +1,82 @@
package com.octopus.openfeature.provider;

import dev.openfeature.sdk.*;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import dev.openfeature.sdk.exceptions.ParseError;
import org.apache.commons.codec.digest.MurmurHash3;

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;

import static java.util.stream.Collectors.groupingBy;

/**
* Holds one evaluation response and resolves a flag from it, applying any client-side rules the
* server deferred.
*/
class OctopusContext {

private static final System.Logger logger = System.getLogger(OctopusClient.class.getName());
private final FeatureToggles featureToggles;
private static final System.Logger logger = System.getLogger(OctopusContext.class.getName());

OctopusContext(FeatureToggles featureToggles) {
this.featureToggles = featureToggles;
}
private final EvaluationResponse evaluationResponse;
private final UnknownSlugs unknownSlugs;

static OctopusContext empty() {
return new OctopusContext(new FeatureToggles(List.of(), new byte[0]));
OctopusContext(EvaluationResponse evaluationResponse) {
this(evaluationResponse, new UnknownSlugs());
}

byte[] getContentHash() {
return featureToggles.getContentHash();
OctopusContext(EvaluationResponse evaluationResponse, UnknownSlugs unknownSlugs) {
this.evaluationResponse = evaluationResponse;
this.unknownSlugs = unknownSlugs;
}

FeatureToggleEvaluation findFeatureToggleBySlug(String slug) {
return featureToggles.getEvaluations().stream()
.filter(f -> f.getSlug().equalsIgnoreCase(slug))
.findFirst().orElse(null);
static OctopusContext empty() {
return new OctopusContext(new EvaluationResponse(List.of(), new byte[0]));
}

ProviderEvaluation<Boolean> evaluate(String slug, Boolean defaultValue, EvaluationContext evaluationContext) {
var toggleValue = findFeatureToggleBySlug(slug);

if (toggleValue == null) {
throw new FlagNotFoundError();
}

if (missingRequiredPropertiesForClientSideEvaluation(toggleValue)) {
throw new ParseError("Feature toggle " + toggleValue.getSlug() + " is missing necessary information for client-side evaluation.");
}

if (!toggleValue.isEnabled()) {
return ProviderEvaluation.<Boolean>builder()
.value(false)
.reason(Reason.DEFAULT.toString())
.build();
}

// EvaluationKey and ClientRolloutPercentage are guaranteed non-null here via missingRequiredPropertiesForClientSideEvaluation()
String evaluationKey = toggleValue.getEvaluationKey().orElseThrow();
int rolloutPercentage = toggleValue.getClientRolloutPercentage().orElseThrow();
String targetingKey = evaluationContext != null ? evaluationContext.getTargetingKey() : null;

if (targetingKey == null || targetingKey.isEmpty()) {
if (rolloutPercentage < 100) {
return ProviderEvaluation.<Boolean>builder()
.value(false)
.reason(Reason.TARGETING_MATCH.toString())
.build();
}
// rolloutPercentage == 100: fall through to segment check
} else {
if (getNormalizedNumber(evaluationKey, targetingKey) > rolloutPercentage) {
return ProviderEvaluation.<Boolean>builder()
.value(false)
.reason(Reason.TARGETING_MATCH.toString())
.build();
}
}

if (!toggleValue.hasSegments()) {
return ProviderEvaluation.<Boolean>builder()
.value(true)
.reason(Reason.DEFAULT.toString())
.build();
}

var segments = toggleValue.getSegments().orElseThrow();
static OctopusContext empty(UnknownSlugs unknownSlugs) {
return new OctopusContext(new EvaluationResponse(List.of(), new byte[0]), unknownSlugs);
}

return ProviderEvaluation.<Boolean>builder()
.value(matchesSegment(evaluationContext, segments))
.reason(Reason.TARGETING_MATCH.toString())
.build();
byte[] getContentHash() {
return evaluationResponse.getContentHash();
}

private boolean missingRequiredPropertiesForClientSideEvaluation(FeatureToggleEvaluation evaluation) {
if (!evaluation.isEnabled()) {
return false;
ServerSideEvaluation findEvaluationBySlug(String slug) {
var evaluations = evaluationResponse.getEvaluations();
if (slug == null || evaluations == null) {
return null;
}

return evaluation.getClientRolloutPercentage().isEmpty()
|| evaluation.getEvaluationKey().isEmpty()
|| evaluation.getSegments().isEmpty();
// A null entry carries no slug, so it can never be the flag being asked for. Skipping it keeps a
// malformed entry from costing every other flag in the response.
return evaluations.stream()
.filter(Objects::nonNull)
.filter(evaluation -> slug.equalsIgnoreCase(evaluation.getSlug()))
.findFirst().orElse(null);
}

static int getNormalizedNumber(String evaluationKey, String targetingKey) {
byte[] bytes = (evaluationKey + ":" + targetingKey).getBytes(StandardCharsets.UTF_8);

// MurmurHash3 32-bit, seed 0. hash32x86 processes tail bytes in little-endian order,
// matching the reference C spec and equivalent to .NET's MurmurHash.Create32() +
// BinaryPrimitives.ReadUInt32LittleEndian().
int hash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0);
ProviderEvaluation<Boolean> evaluate(String slug, EvaluationContext evaluationContext) {
var serverSideEvaluation = findEvaluationBySlug(slug);

// Java has no unsigned integer type. Integer.toUnsignedLong() reinterprets the signed
// int as an unsigned 32-bit value (widened to long) — equivalent to casting to uint in C#.
long unsignedHash = Integer.toUnsignedLong(hash);

return (int) (unsignedHash % 100) + 1;
}
if (serverSideEvaluation == null) {
if (unknownSlugs.shouldWarnAbout(slug)) {
logger.log(System.Logger.Level.WARNING, String.format(
"The slug %s did not match any of your Octopus Feature Flags. Please double check your slug and try again.",
slug));
}

static boolean matchesSegment(EvaluationContext evaluationContext, List<Segment> segments) {
if (evaluationContext == null) {
return false;
throw new FlagNotFoundError(
"The slug provided did not match any of your Octopus Feature Flags. Please double check your slug and try again.");
}

var contextEntries = evaluationContext.asMap();
var groupedByKey = segments.stream().collect(groupingBy(Segment::getKey));
return groupedByKey.keySet().stream().allMatch(k -> {
var values = groupedByKey.get(k);

return contextEntries.keySet().stream().anyMatch(
c -> c.equalsIgnoreCase(k) && values.stream().anyMatch(
v -> v.getValue().equalsIgnoreCase(contextEntries.get(c).asString())));

});
try {
return serverSideEvaluation.evaluate(evaluationContext);
} catch (ParseError e) {
// The message is shared verbatim with the other provider libraries, so it names the problem
// but not the flag. Logging the slug beside it is what makes a malformed response traceable
// when several flags are affected at once.
logger.log(System.Logger.Level.WARNING, String.format(
"Could not evaluate feature flag %s: %s", slug, e.getMessage()));
throw e;
}
}

}
Loading