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
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@
<artifactId>jackson-databind</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.21.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion specification
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ FeatureToggles getFeatureToggleEvaluationManifest()
logger.log(System.Logger.Level.WARNING,String.format("Feature toggle response from %s did not contain expected ContentHash header", manifestURI.toString()));
return null;
}
List<FeatureToggleEvaluation> evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<>(){});
var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>(){});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I lied in my last PR. Well, not that I lied, but I did think we were on Java 8 rather than 11.

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);
Expand Down
65 changes: 55 additions & 10 deletions src/main/java/com/octopus/openfeature/provider/OctopusContext.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.octopus.openfeature.provider;

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

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

import static java.util.stream.Collectors.groupingBy;
Expand All @@ -26,10 +28,10 @@ byte[] getContentHash() {
}

ProviderEvaluation<Boolean> evaluate(String slug, Boolean defaultValue, EvaluationContext evaluationContext) {
Copy link
Copy Markdown
Contributor Author

@liamhughes liamhughes Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method mostly matches the .NET equivalent logically, though with a different shape. I propose that we align them further prior to the next piece of work in this area.

// find the feature toggle matching the slug
var toggleValue = featureToggles.getEvaluations().stream().filter(f -> f.getSlug().equalsIgnoreCase(slug)).findFirst().orElse(null);
var toggleValue = featureToggles.getEvaluations().stream()
.filter(f -> f.getSlug().equalsIgnoreCase(slug))
.findFirst().orElse(null);

// this exception will be handled by OpenFeature, and the default value will be used
if (toggleValue == null) {
throw new FlagNotFoundError();
}
Expand All @@ -38,18 +40,46 @@ ProviderEvaluation<Boolean> evaluate(String slug, Boolean defaultValue, Evaluati
throw new ParseError("Feature toggle " + toggleValue.getSlug() + " is missing necessary information for client-side evaluation.");
}

// if the toggle is disabled, or if it has no segments, then we don't need to evaluate dynamically
if (!toggleValue.isEnabled() || !toggleValue.hasSegments()) {
if (!toggleValue.isEnabled()) {
return ProviderEvaluation.<Boolean>builder()
.value(toggleValue.isEnabled())
.value(false)
.reason(Reason.DEFAULT.toString())
.build();
}

// If the toggle is enabled and has segments configured, then we need to evaluate dynamically,
// checking the context matches the segments
// 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())
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.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();

return ProviderEvaluation.<Boolean>builder()
.value(matchesSegment(evaluationContext, toggleValue.getSegments().orElseThrow())) // checked in hasSegments
.value(matchesSegment(evaluationContext, segments))
.reason(Reason.TARGETING_MATCH.toString())
.build();
}
Expand All @@ -64,7 +94,22 @@ private boolean missingRequiredPropertiesForClientSideEvaluation(FeatureToggleEv
|| evaluation.getSegments().isEmpty();
}

private Boolean matchesSegment(EvaluationContext evaluationContext, List<Segment> segments) {
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);

// 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;
}

static boolean matchesSegment(EvaluationContext evaluationContext, List<Segment> segments) {
if (evaluationContext == null) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,9 @@ void shouldDeserializeToggleWithSegments() throws Exception {

@Test
void shouldDeserializeListOfToggles() throws Exception {
List<FeatureToggleEvaluation> result = objectMapper.readValue(
var result = objectMapper.readValue(
resource("toggle-list.json"),
new TypeReference<>() {
}
new TypeReference<List<FeatureToggleEvaluation>>() {}
);

assertThat(result).hasSize(2);
Expand All @@ -85,10 +84,9 @@ void shouldDeserializeListOfToggles() throws Exception {

@Test
void shouldDeserializeListOfTogglesWithVariousFieldCasings() throws Exception {
List<FeatureToggleEvaluation> result = objectMapper.readValue(
var result = objectMapper.readValue(
resource("toggles-with-different-field-capitalisation.json"),
new TypeReference<>() {
}
new TypeReference<List<FeatureToggleEvaluation>>() {}
);

assertThat(result).hasSize(3);
Expand Down
Loading
Loading