Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.3.0-jre</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 com.google.common.hash.Hashing;
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. murmur3_32_fixed is Guava's corrected implementation
// that processes tail bytes in little-endian order, matching the reference C spec and
// equivalent to .NET's MurmurHash.Create32() + BinaryPrimitives.ReadUInt32LittleEndian().
int hash = Hashing.murmur3_32_fixed(0).hashBytes(bytes).asInt();

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

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