diff --git a/specification b/specification index ef84509..67890d8 160000 --- a/specification +++ b/specification @@ -1 +1 @@ -Subproject commit ef8450956f7619b742b0983fb99f664426b36c8d +Subproject commit 67890d834996dc65e130716e9246c8754ad1aeda diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java b/src/main/java/com/octopus/openfeature/provider/ClientSideCondition.java similarity index 96% rename from src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java rename to src/main/java/com/octopus/openfeature/provider/ClientSideCondition.java index 708a3bd..5fc9b39 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/ClientSideCondition.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializer.java b/src/main/java/com/octopus/openfeature/provider/ClientSideConditionDeserializer.java similarity index 98% rename from src/main/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializer.java rename to src/main/java/com/octopus/openfeature/provider/ClientSideConditionDeserializer.java index fab619f..10e23c7 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializer.java +++ b/src/main/java/com/octopus/openfeature/provider/ClientSideConditionDeserializer.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideEvaluationContext.java b/src/main/java/com/octopus/openfeature/provider/ClientSideEvaluationContext.java similarity index 94% rename from src/main/java/com/octopus/openfeature/provider/v4/ClientSideEvaluationContext.java rename to src/main/java/com/octopus/openfeature/provider/ClientSideEvaluationContext.java index 73a334c..796a4e0 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideEvaluationContext.java +++ b/src/main/java/com/octopus/openfeature/provider/ClientSideEvaluationContext.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.EvaluationContext; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java b/src/main/java/com/octopus/openfeature/provider/ClientSideRule.java similarity index 97% rename from src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java rename to src/main/java/com/octopus/openfeature/provider/ClientSideRule.java index 90f19dd..d821317 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java +++ b/src/main/java/com/octopus/openfeature/provider/ClientSideRule.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java b/src/main/java/com/octopus/openfeature/provider/ConditionTypeNames.java similarity index 91% rename from src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java rename to src/main/java/com/octopus/openfeature/provider/ConditionTypeNames.java index f8f8cff..a7897fb 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java +++ b/src/main/java/com/octopus/openfeature/provider/ConditionTypeNames.java @@ -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 diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java b/src/main/java/com/octopus/openfeature/provider/ContextAttributeIsNotOneOfCondition.java similarity index 96% rename from src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java rename to src/main/java/com/octopus/openfeature/provider/ContextAttributeIsNotOneOfCondition.java index 6a1f037..1dea7fa 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/ContextAttributeIsNotOneOfCondition.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java b/src/main/java/com/octopus/openfeature/provider/ContextAttributeIsOneOfCondition.java similarity index 96% rename from src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java rename to src/main/java/com/octopus/openfeature/provider/ContextAttributeIsOneOfCondition.java index 9d50ac5..dbb6bc3 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/ContextAttributeIsOneOfCondition.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributes.java b/src/main/java/com/octopus/openfeature/provider/ContextAttributes.java similarity index 97% rename from src/main/java/com/octopus/openfeature/provider/v4/ContextAttributes.java rename to src/main/java/com/octopus/openfeature/provider/ContextAttributes.java index 5f8f2fe..afc6bbf 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributes.java +++ b/src/main/java/com/octopus/openfeature/provider/ContextAttributes.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/EvaluationReasons.java b/src/main/java/com/octopus/openfeature/provider/EvaluationReasons.java similarity index 91% rename from src/main/java/com/octopus/openfeature/provider/v4/EvaluationReasons.java rename to src/main/java/com/octopus/openfeature/provider/EvaluationReasons.java index 072c0d0..fd65101 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/EvaluationReasons.java +++ b/src/main/java/com/octopus/openfeature/provider/EvaluationReasons.java @@ -1,4 +1,4 @@ -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 diff --git a/src/main/java/com/octopus/openfeature/provider/EvaluationResponse.java b/src/main/java/com/octopus/openfeature/provider/EvaluationResponse.java new file mode 100644 index 0000000..9022a72 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/EvaluationResponse.java @@ -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 evaluations; + private final byte[] contentHash; + + EvaluationResponse(List evaluations, byte[] contentHash) { + this.evaluations = evaluations; + this.contentHash = contentHash; + } + + public List getEvaluations() { + return evaluations; + } + + public byte[] getContentHash() { + return contentHash; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java b/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java index 3d2db59..21b698a 100644 --- a/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java +++ b/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/FeatureToggles.java b/src/main/java/com/octopus/openfeature/provider/FeatureToggles.java index 987c77b..1500ea9 100644 --- a/src/main/java/com/octopus/openfeature/provider/FeatureToggles.java +++ b/src/main/java/com/octopus/openfeature/provider/FeatureToggles.java @@ -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 evaluations; private final byte[] contentHash; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ListUtils.java b/src/main/java/com/octopus/openfeature/provider/ListUtils.java similarity index 94% rename from src/main/java/com/octopus/openfeature/provider/v4/ListUtils.java rename to src/main/java/com/octopus/openfeature/provider/ListUtils.java index fad17f4..ded497d 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ListUtils.java +++ b/src/main/java/com/octopus/openfeature/provider/ListUtils.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import java.util.ArrayList; import java.util.Collections; diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index 59ea702..87c37eb 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -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; } @@ -56,31 +56,38 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash) throws IOException, Interr .header("X-Octopus-Client", buildOctopusClientHeaderValue()) .build(); HttpResponse 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 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 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>() {}); - return new FeatureToggles(evaluations, Base64.getDecoder().decode(contentHashHeader.get())); + var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference>() {}); + 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() { @@ -95,16 +102,16 @@ 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 { } @@ -112,7 +119,7 @@ private URI getManifestURI() { } // This class needs to be static to allow deserialization - private static class FeatureToggleCheckResponse { + private static class FeatureFlagCheckResponse { public byte[] contentHash; } } diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusContext.java b/src/main/java/com/octopus/openfeature/provider/OctopusContext.java index 2bd33a3..1138bbc 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusContext.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusContext.java @@ -1,133 +1,75 @@ 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.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Holds one evaluation response and resolves a flag from it, applying any client-side rules the + * server deferred. + */ +class OctopusContext { -import static java.util.stream.Collectors.groupingBy; + private static final System.Logger logger = System.getLogger(OctopusContext.class.getName()); -class OctopusContext { + private final EvaluationResponse evaluationResponse; - private static final System.Logger logger = System.getLogger(OctopusClient.class.getName()); - private final FeatureToggles featureToggles; + /** + * The slugs already warned about. An unrecognised slug is usually a typo, which would otherwise log + * on every evaluation of it. Held here rather than on the cache, matching the other provider + * libraries: a new response starts a new set, so a persistent typo is reported again whenever the + * flags change. + */ + private final Set warnedSlugs = ConcurrentHashMap.newKeySet(); - OctopusContext(FeatureToggles featureToggles) { - this.featureToggles = featureToggles; + OctopusContext(EvaluationResponse evaluationResponse) { + this.evaluationResponse = evaluationResponse; } static OctopusContext empty() { - return new OctopusContext(new FeatureToggles(List.of(), new byte[0])); + return new OctopusContext(new EvaluationResponse(List.of(), new byte[0])); } byte[] getContentHash() { - return featureToggles.getContentHash(); - } - - FeatureToggleEvaluation findFeatureToggleBySlug(String slug) { - return featureToggles.getEvaluations().stream() - .filter(f -> f.getSlug().equalsIgnoreCase(slug)) - .findFirst().orElse(null); - } - - ProviderEvaluation 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.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.builder() - .value(false) - .reason(Reason.TARGETING_MATCH.toString()) - .build(); - } - // rolloutPercentage == 100: fall through to segment check - } else { - if (getNormalizedNumber(evaluationKey, targetingKey) > rolloutPercentage) { - return ProviderEvaluation.builder() - .value(false) - .reason(Reason.TARGETING_MATCH.toString()) - .build(); - } - } - - if (!toggleValue.hasSegments()) { - return ProviderEvaluation.builder() - .value(true) - .reason(Reason.DEFAULT.toString()) - .build(); - } - - var segments = toggleValue.getSegments().orElseThrow(); - - return ProviderEvaluation.builder() - .value(matchesSegment(evaluationContext, segments)) - .reason(Reason.TARGETING_MATCH.toString()) - .build(); + 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); + ProviderEvaluation evaluate(String slug, EvaluationContext evaluationContext) { + var serverSideEvaluation = findEvaluationBySlug(slug); - // 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; - } + if (serverSideEvaluation == null) { + // Locale.ROOT, not the default locale: under a Turkish locale "MY-FLAG-I" lowercases to a + // dotless "my-flag-ı", which would not match "my-flag-i" and so would warn twice. + if (warnedSlugs.add(slug == null ? "" : slug.toLowerCase(Locale.ROOT))) { + 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 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()))); - - }); + return serverSideEvaluation.evaluate(evaluationContext); } - } diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusContextProvider.java b/src/main/java/com/octopus/openfeature/provider/OctopusContextProvider.java index c5c042a..c72bfa2 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusContextProvider.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusContextProvider.java @@ -21,10 +21,12 @@ void initialize() { } try { - var toggles = client.getFeatureToggleEvaluationManifest(); - currentContext = toggles == null ? OctopusContext.empty() : new OctopusContext(toggles); + var evaluationResponse = client.getServerSideEvaluations(); + currentContext = evaluationResponse == null + ? OctopusContext.empty() + : new OctopusContext(evaluationResponse); } catch (Exception e) { - logger.log(System.Logger.Level.ERROR, "Failed to retrieve feature manifest during initialization. Falling back to empty context, defaults will be used during evaluation.", e); + logger.log(System.Logger.Level.ERROR, "Failed to retrieve feature flag evaluations during initialization. Falling back to empty context, defaults will be used during evaluation.", e); currentContext = OctopusContext.empty(); } @@ -45,19 +47,19 @@ void refresh() { try { Thread.sleep(config.getCacheDuration().toMillis()); - if (client.haveFeatureTogglesChanged(currentContext.getContentHash())) { - var toggles = client.getFeatureToggleEvaluationManifest(); - if (toggles != null) { - currentContext = new OctopusContext(toggles); + if (client.haveFeatureFlagsChanged(currentContext.getContentHash())) { + var evaluationResponse = client.getServerSideEvaluations(); + if (evaluationResponse != null) { + currentContext = new OctopusContext(evaluationResponse); } else { - logger.log(System.Logger.Level.ERROR, "Failed to retrieve updated feature manifest. Retaining existing context which may be stale."); + logger.log(System.Logger.Level.ERROR, "Failed to retrieve updated feature flag evaluations. Retaining existing context which may be stale."); } } } catch (InterruptedException e) { // the loop will be terminated and the thread will finish Thread.currentThread().interrupt(); } catch (Exception e) { - logger.log(System.Logger.Level.ERROR, "Failed to retrieve updated feature manifest. Retaining existing context which may be stale.", e); + logger.log(System.Logger.Level.ERROR, "Failed to retrieve updated feature flag evaluations. Retaining existing context which may be stale.", e); } } } diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java b/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java index 0d169a0..b66a409 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java @@ -37,7 +37,7 @@ public void shutdown() { @Override public ProviderEvaluation getBooleanEvaluation(String flagKey, Boolean defaultValue, EvaluationContext evaluationContext) { - return contextProvider.getOctopusContext().evaluate(flagKey, defaultValue, evaluationContext); + return contextProvider.getOctopusContext().evaluate(flagKey, evaluationContext); } @Override @@ -61,9 +61,10 @@ public ProviderEvaluation getObjectEvaluation(String flagKey, Value defau } private RuntimeException rejectNonBooleanEvaluation(String flagKey) { - var toggle = contextProvider.getOctopusContext().findFeatureToggleBySlug(flagKey); - if (toggle == null) { - return new FlagNotFoundError(flagKey); + var evaluation = contextProvider.getOctopusContext().findEvaluationBySlug(flagKey); + if (evaluation == null) { + return new FlagNotFoundError( + "The slug provided did not match any of your Octopus Feature Flags. Please double check your slug and try again."); } return new TypeMismatchError("Octopus only supports boolean flags."); } diff --git a/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java b/src/main/java/com/octopus/openfeature/provider/PercentageByContextCondition.java similarity index 98% rename from src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java rename to src/main/java/com/octopus/openfeature/provider/PercentageByContextCondition.java index 4dc156a..0ea1304 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/PercentageByContextCondition.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/PercentageRollout.java b/src/main/java/com/octopus/openfeature/provider/PercentageRollout.java similarity index 77% rename from src/main/java/com/octopus/openfeature/provider/v4/PercentageRollout.java rename to src/main/java/com/octopus/openfeature/provider/PercentageRollout.java index 06814a7..9a1c9f9 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/PercentageRollout.java +++ b/src/main/java/com/octopus/openfeature/provider/PercentageRollout.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import org.apache.commons.codec.digest.MurmurHash3; @@ -9,11 +9,8 @@ * the targeting key keeps a bucket stable across evaluations, while giving each flag an independent * spread of targeting keys. * - *

The v3 path has its own copy of this hash in {@code OctopusContext}, because Java package access - * is not hierarchical and a package-private type here is invisible to that package. Keeping both - * package-private is worth the duplication: the alternative is a public type that consumers could - * bind to, and this one is due to disappear along with v3. Both copies are pinned to the same shared - * vectors — see {@code RolloutVectors} in the tests — so the two cannot drift apart unnoticed. + *

Shared by every percentage rollout in the library, and matching the equivalent implementations in + * the other Octopus OpenFeature provider libraries — see {@code RolloutVectors} in the tests. */ final class PercentageRollout { diff --git a/src/main/java/com/octopus/openfeature/provider/Segment.java b/src/main/java/com/octopus/openfeature/provider/Segment.java index 0ca3fda..da06270 100644 --- a/src/main/java/com/octopus/openfeature/provider/Segment.java +++ b/src/main/java/com/octopus/openfeature/provider/Segment.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +// TODO(BMBB-780): a v3 type, unused since the switch to v4. class Segment { private final String key; private final String value; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java b/src/main/java/com/octopus/openfeature/provider/ServerSideEvaluation.java similarity index 98% rename from src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java rename to src/main/java/com/octopus/openfeature/provider/ServerSideEvaluation.java index 5a4c99b..d6de652 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java +++ b/src/main/java/com/octopus/openfeature/provider/ServerSideEvaluation.java @@ -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; diff --git a/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java b/src/main/java/com/octopus/openfeature/provider/UnknownCondition.java similarity index 97% rename from src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java rename to src/main/java/com/octopus/openfeature/provider/UnknownCondition.java index 058846d..d016b96 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/UnknownCondition.java @@ -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; diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/ClientSideConditionDeserializationTests.java similarity index 96% rename from src/test/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializationTests.java rename to src/test/java/com/octopus/openfeature/provider/ClientSideConditionDeserializationTests.java index d03baf0..14e8bd7 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ClientSideConditionDeserializationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ClientSideConditionDeserializationTests.java @@ -1,8 +1,7 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.octopus.openfeature.provider.TestObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -19,7 +18,7 @@ */ class ClientSideConditionDeserializationTests { - private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + private final ObjectMapper objectMapper = OctopusObjectMapper.INSTANCE; private InputStream resource(String name) { return getClass().getResourceAsStream(name); diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ClientSideRuleTests.java b/src/test/java/com/octopus/openfeature/provider/ClientSideRuleTests.java similarity index 95% rename from src/test/java/com/octopus/openfeature/provider/v4/ClientSideRuleTests.java rename to src/test/java/com/octopus/openfeature/provider/ClientSideRuleTests.java index a4d3296..59b5b84 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ClientSideRuleTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ClientSideRuleTests.java @@ -1,7 +1,6 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import com.fasterxml.jackson.databind.ObjectMapper; -import com.octopus.openfeature.provider.TestObjectMapper; import dev.openfeature.sdk.exceptions.ParseError; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -17,7 +16,7 @@ class ClientSideRuleTests { - private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + private final ObjectMapper objectMapper = OctopusObjectMapper.INSTANCE; private static ClientSideRule rule(ClientSideCondition... conditions) { return new ClientSideRule("Rule 1", Arrays.asList(conditions)); diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfConditionTests.java b/src/test/java/com/octopus/openfeature/provider/ContextAttributeIsNotOneOfConditionTests.java similarity index 98% rename from src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfConditionTests.java rename to src/test/java/com/octopus/openfeature/provider/ContextAttributeIsNotOneOfConditionTests.java index 317c815..dfd0cec 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfConditionTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ContextAttributeIsNotOneOfConditionTests.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.exceptions.ParseError; diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfConditionTests.java b/src/test/java/com/octopus/openfeature/provider/ContextAttributeIsOneOfConditionTests.java similarity index 98% rename from src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfConditionTests.java rename to src/test/java/com/octopus/openfeature/provider/ContextAttributeIsOneOfConditionTests.java index 674eb62..0bdecce 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfConditionTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ContextAttributeIsOneOfConditionTests.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.exceptions.ParseError; diff --git a/src/test/java/com/octopus/openfeature/provider/v4/Contexts.java b/src/test/java/com/octopus/openfeature/provider/Contexts.java similarity index 97% rename from src/test/java/com/octopus/openfeature/provider/v4/Contexts.java rename to src/test/java/com/octopus/openfeature/provider/Contexts.java index 92fc0fb..8b56fbb 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/Contexts.java +++ b/src/test/java/com/octopus/openfeature/provider/Contexts.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.MutableContext; diff --git a/src/test/java/com/octopus/openfeature/provider/v4/MalformedEvaluationTests.java b/src/test/java/com/octopus/openfeature/provider/MalformedEvaluationTests.java similarity index 98% rename from src/test/java/com/octopus/openfeature/provider/v4/MalformedEvaluationTests.java rename to src/test/java/com/octopus/openfeature/provider/MalformedEvaluationTests.java index 763ba43..5345707 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/MalformedEvaluationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/MalformedEvaluationTests.java @@ -1,8 +1,7 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.octopus.openfeature.provider.TestObjectMapper; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.exceptions.ParseError; @@ -27,7 +26,7 @@ */ class MalformedEvaluationTests { - private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + private final ObjectMapper objectMapper = OctopusObjectMapper.INSTANCE; /** * Satisfies every rule below, so a flag that failed to throw would visibly turn on. diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java index a80dfb9..f99bba5 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java @@ -1,10 +1,20 @@ package com.octopus.openfeature.provider; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.net.URI; +import java.util.Base64; import java.util.Properties; +import java.util.stream.Collectors; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static org.assertj.core.api.Assertions.assertThat; class OctopusClientTests { @@ -55,4 +65,88 @@ void buildOctopusClientHeaderValue_withNameContainingUnsupportedChars_stripsChar assertThat(client.buildOctopusClientHeaderValue()) .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); } + + private static final String CHECK_PATH = "/api/feature-flags/check/v4/"; + private static final String EVALUATIONS_PATH = "/api/feature-flags/evaluations/v4/"; + private static final String CONTENT_HASH = Base64.getEncoder().encodeToString(new byte[]{0x01, 0x02}); + + private WireMockServer wireMock; + + @BeforeEach + void startServer() { + wireMock = new WireMockServer(wireMockConfig().dynamicPort()); + wireMock.start(); + } + + @AfterEach + void stopServer() { + wireMock.stop(); + } + + private OctopusClient clientForServer() { + var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct")); + config.setServerUri(URI.create(wireMock.baseUrl())); + return new OctopusClient(config); + } + + private String requestedPaths() { + return wireMock.getAllServeEvents().stream() + .map(event -> event.getRequest().getUrl()) + .collect(Collectors.joining(", ")); + } + + @Test + void haveFeatureFlagsChanged_requestsTheV4CheckEndpoint() throws Exception { + wireMock.stubFor(get(anyUrl()).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"contentHash\":\"" + CONTENT_HASH + "\"}"))); + + var haveChanged = clientForServer().haveFeatureFlagsChanged(new byte[]{0x03, 0x04}); + + assertThat(requestedPaths()).isEqualTo(CHECK_PATH); + assertThat(haveChanged).isTrue(); + } + + @Test + void haveFeatureFlagsChanged_whenTheContentHashIsUnchanged_reportsNoChange() throws Exception { + wireMock.stubFor(get(anyUrl()).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"contentHash\":\"" + CONTENT_HASH + "\"}"))); + + var haveChanged = clientForServer().haveFeatureFlagsChanged(new byte[]{0x01, 0x02}); + + assertThat(haveChanged).isFalse(); + } + + @Test + void getServerSideEvaluations_whenTheBodyIsEmpty_reportsAFailedFetch() throws Exception { + wireMock.stubFor(get(anyUrl()).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("ContentHash", CONTENT_HASH) + .withBody("null"))); + + // Null rather than an empty response: the cache keeps refetching instead of settling on a content + // hash that the check endpoint will report as unchanged forever. + assertThat(clientForServer().getServerSideEvaluations()).isNull(); + } + + @Test + void getServerSideEvaluations_requestsTheV4EvaluationsEndpoint() throws Exception { + wireMock.stubFor(get(anyUrl()).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("ContentHash", CONTENT_HASH) + .withBody("[{\"slug\":\"test-feature\",\"value\":true,\"reason\":\"The flag is enabled for this environment.\"}]"))); + + var response = clientForServer().getServerSideEvaluations(); + + assertThat(requestedPaths()).isEqualTo(EVALUATIONS_PATH); + assertThat(response).isNotNull(); + assertThat(response.getContentHash()).isEqualTo(new byte[]{0x01, 0x02}); + assertThat(response.getEvaluations()).singleElement() + .satisfies(evaluation -> assertThat(evaluation.getSlug()).isEqualTo("test-feature")); + } } diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusContextProviderTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusContextProviderTests.java index 3bfd7d6..28b3c75 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusContextProviderTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusContextProviderTests.java @@ -15,28 +15,36 @@ class OctopusContextProviderTests { static class MockOctopusFeatureClient extends OctopusClient { - private volatile FeatureToggles toggles; + private volatile EvaluationResponse evaluationResponse; - MockOctopusFeatureClient(FeatureToggles toggles) { + MockOctopusFeatureClient(EvaluationResponse evaluationResponse) { super(null); - this.toggles = toggles; + this.evaluationResponse = evaluationResponse; } - void changeToggles(FeatureToggles toggles) { - this.toggles = toggles; + void changeEvaluations(EvaluationResponse evaluationResponse) { + this.evaluationResponse = evaluationResponse; } @Override - Boolean haveFeatureTogglesChanged(byte[] contentHash) { + Boolean haveFeatureFlagsChanged(byte[] contentHash) { return true; } @Override - FeatureToggles getFeatureToggleEvaluationManifest() { - return toggles; + EvaluationResponse getServerSideEvaluations() { + return evaluationResponse; } } + private static EvaluationResponse response(boolean value, byte[] contentHash) { + return new EvaluationResponse( + List.of(new ServerSideEvaluation("test-feature", value, + value ? "The flag is enabled for this environment." : "The flag is disabled for this environment.", + null, null)), + contentHash); + } + private final OctopusConfiguration configuration = configure(); private static OctopusConfiguration configure() { @@ -51,10 +59,7 @@ void whenInitialized_RefreshesCacheAfterCacheDurationExpires() throws Interrupte byte[] initialHash = {0x01, 0x02, 0x03, 0x04}; byte[] updatedHash = {0x01, 0x02, 0x03, 0x05}; - var client = new MockOctopusFeatureClient(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - initialHash - )); + var client = new MockOctopusFeatureClient(response(true, initialHash)); var provider = new OctopusContextProvider(configuration, client); provider.initialize(); @@ -62,20 +67,17 @@ void whenInitialized_RefreshesCacheAfterCacheDurationExpires() throws Interrupte try { // Validate the initial state assertThat(provider.getOctopusContext().getContentHash()).isEqualTo(initialHash); - assertThat(provider.getOctopusContext().evaluate("test-feature", false, null).getValue()).isTrue(); + assertThat(provider.getOctopusContext().evaluate("test-feature", null).getValue()).isTrue(); // Simulate a change in the available feature toggles - client.changeToggles(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", false, "evaluation-key", Collections.emptyList(), 100)), - updatedHash - )); + client.changeEvaluations(response(false, updatedHash)); // Wait for the cache to expire Thread.sleep(500); // Validate the updated toggles are available assertThat(provider.getOctopusContext().getContentHash()).isEqualTo(updatedHash); - assertThat(provider.getOctopusContext().evaluate("test-feature", true, null).getValue()).isFalse(); + assertThat(provider.getOctopusContext().evaluate("test-feature", null).getValue()).isFalse(); } finally { provider.shutdown(); @@ -87,10 +89,7 @@ void whenInitialized_AndRefreshFails_RetainsExistingContextAndLogsError() throws byte[] contentHash = {0x01, 0x02, 0x03, 0x04}; - var client = new MockOctopusFeatureClient(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - contentHash - )); + var client = new MockOctopusFeatureClient(response(true, contentHash)); var logMessages = new ArrayList(); var julLogger = Logger.getLogger(OctopusClient.class.getName()); @@ -110,15 +109,15 @@ void whenInitialized_AndRefreshFails_RetainsExistingContextAndLogsError() throws julLogger.setUseParentHandlers(false); // Simulate a failed fetch - client.changeToggles(null); + client.changeEvaluations(null); // Wait for the cache to expire Thread.sleep(500); // Validate that the existing context is retained and an error was logged assertThat(provider.getOctopusContext().getContentHash()).isEqualTo(contentHash); - assertThat(provider.getOctopusContext().evaluate("test-feature", false, null).getValue()).isTrue(); - assertThat(logMessages).anyMatch(m -> m.startsWith("Failed to retrieve updated feature manifest")); + assertThat(provider.getOctopusContext().evaluate("test-feature", null).getValue()).isTrue(); + assertThat(logMessages).anyMatch(m -> m.startsWith("Failed to retrieve updated feature flag evaluations")); } finally { julLogger.removeHandler(handler); @@ -145,10 +144,7 @@ void whenInitialFetchReturnsNothing_AndRefreshSucceeds_ContextIsPopulated() thro assertThat(provider.getOctopusContext().getContentHash()).isEmpty(); // Update client to return valid toggles and wait for refresh - client.changeToggles(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", false, "evaluation-key", Collections.emptyList(), 100)), - contentHash - )); + client.changeEvaluations(response(false, contentHash)); Thread.sleep(5000); // Assert that the context is now correctly populated @@ -176,10 +172,7 @@ void whenRefreshReturnsNothing_AndSubsequentRefreshSucceeds_ContextIsUpdated() t julLogger.addHandler(handler); // initialize with a client that returns valid toggles - var client = new MockOctopusFeatureClient(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - initialHash - )); + var client = new MockOctopusFeatureClient(response(true, initialHash)); var provider = new OctopusContextProvider(configuration, client); provider.initialize(); @@ -188,18 +181,15 @@ void whenRefreshReturnsNothing_AndSubsequentRefreshSucceeds_ContextIsUpdated() t try { // Switch to a null client and wait for refresh to fail - client.changeToggles(null); + client.changeEvaluations(null); Thread.sleep(5000); // Assert that failed refresh is logged and old context is retained - assertThat(logMessages).anyMatch(m -> m.startsWith("Failed to retrieve updated feature manifest")); + assertThat(logMessages).anyMatch(m -> m.startsWith("Failed to retrieve updated feature flag evaluations")); assertThat(provider.getOctopusContext().getContentHash()).isEqualTo(initialHash); // Update client to return valid toggles again and wait for refresh - client.changeToggles(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", false, "evaluation-key", Collections.emptyList(), 100)), - updatedHash - )); + client.changeEvaluations(response(false, updatedHash)); Thread.sleep(5000); assertThat(provider.getOctopusContext().getContentHash()).isEqualTo(updatedHash); @@ -214,20 +204,20 @@ void whenRefreshReturnsNothing_AndSubsequentRefreshSucceeds_ContextIsUpdated() t static class ThrowsOnRefreshClient extends OctopusClient { static final String ERROR_MESSAGE = "Oops! Simulated refresh error"; - private final FeatureToggles initial; + private final EvaluationResponse initial; - ThrowsOnRefreshClient(FeatureToggles initial) { + ThrowsOnRefreshClient(EvaluationResponse initial) { super(null); this.initial = initial; } @Override - Boolean haveFeatureTogglesChanged(byte[] contentHash) { + Boolean haveFeatureFlagsChanged(byte[] contentHash) { throw new RuntimeException(ERROR_MESSAGE); } @Override - FeatureToggles getFeatureToggleEvaluationManifest() { + EvaluationResponse getServerSideEvaluations() { return initial; } } @@ -249,10 +239,7 @@ void whenAnExceptionIsThrownDuringRefresh_LogsErrorDetails() throws InterruptedE julLogger.setUseParentHandlers(false); // Initialize with a client that will throw on refresh - var client = new ThrowsOnRefreshClient(new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - contentHash - )); + var client = new ThrowsOnRefreshClient(response(true, contentHash)); var provider = new OctopusContextProvider(configuration, client); provider.initialize(); @@ -261,7 +248,7 @@ void whenAnExceptionIsThrownDuringRefresh_LogsErrorDetails() throws InterruptedE Thread.sleep(500); assertThat(logRecords).anyMatch(r -> - r.getMessage().startsWith("Failed to retrieve updated feature manifest") + r.getMessage().startsWith("Failed to retrieve updated feature flag evaluations") && r.getThrown() != null && r.getThrown().getMessage().contains(ThrowsOnRefreshClient.ERROR_MESSAGE) ); diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java index c2e00f3..e4ff8aa 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java @@ -1,291 +1,132 @@ package com.octopus.openfeature.provider; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.MutableContext; +import com.fasterxml.jackson.core.type.TypeReference; +import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import dev.openfeature.sdk.exceptions.ParseError; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import java.util.*; -import java.util.stream.Stream; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - +import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Resolving a flag from an evaluation response. The evaluation itself — server-resolved values, + * client-side rules and malformed responses — is covered by {@link ServerSideEvaluationTests} and the + * suites around it; these cases cover finding the flag, and what happens when it is not there. + */ class OctopusContextTests { - @Test - void evaluatesToTrue_IfFeatureIsContainedWithinTheSet_AndFeatureIsEnabled() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, null).getValue()).isTrue(); - } - - @Test - void whenEvaluatedWithCasingDifferences_EvaluationIsInsensitiveToCase() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("Test-Feature", false, null).getValue()).isTrue(); - } - - @Test - void evaluatesToFalse_IfFeatureIsContainedWithinTheSet_AndFeatureIsNotEnabled() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", false, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, null).getValue()).isFalse(); - } - - @Test - void givenAFlagKeyThatIsNotASlug_ThrowsFlagNotFound() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("this-is-clearly-not-a-slug", true, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThrows(FlagNotFoundError.class, () -> subject.evaluate("This is clearly not a slug!", true, null)); + private static ServerSideEvaluation serverResolved(String slug, boolean value) { + return new ServerSideEvaluation(slug, value, "the server said so", null, null); } - @Test - void throwsFlagNotFound_IfFeatureIsNotContainedWithinSet() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("testfeature", false, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThrows(FlagNotFoundError.class, () -> subject.evaluate("anotherfeature", true, null)); + private static OctopusContext contextWith(ServerSideEvaluation... evaluations) { + return new OctopusContext(new EvaluationResponse(List.of(evaluations), new byte[0])); } @Test - void whenAFeatureIsToggledOnForASpecificSegment_EvaluatesToTrueWhenSegmentIsSpecified() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("testfeature", true, "evaluation-key", List.of(new Segment("license", "trial")), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); + void resolvesAFlagFromTheResponse() { + var result = contextWith(serverResolved("feature-a", true)).evaluate("feature-a", null); - assertThat(subject.evaluate("testfeature", false, buildContext(List.of(Map.entry("license", "trial")))).getValue()).isTrue(); - assertThat(subject.evaluate("testfeature", false, buildContext(List.of(Map.entry("other", "segment")))).getValue()).isFalse(); - assertThat(subject.evaluate("testfeature", false, null).getValue()).isFalse(); + assertThat(result.getValue()).isTrue(); + assertThat(result.getReason()).isEqualTo("the server said so"); + assertThat(result.getErrorCode()).isNull(); } @Test - void whenFeatureIsNotToggledOnForSpecificSegments_EvaluatesToTrueRegardlessOfSegmentSpecified() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("testfeature", true, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); + void resolvesADisabledFlagWithoutError() { + // Off, not defaulted: the flag resolved, it just resolved to false. + var result = contextWith(serverResolved("feature-a", false)).evaluate("feature-a", null); - assertThat(subject.evaluate("testfeature", false, buildContext(List.of(Map.entry("license", "trial")))).getValue()).isTrue(); - assertThat(subject.evaluate("testfeature", false, null).getValue()).isTrue(); + assertThat(result.getValue()).isFalse(); + assertThat(result.getErrorCode()).isNull(); } @Test - void whenAFeatureIsToggledOnForMultipleSegments_EvaluatesCorrectly() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation( - "testfeature", true, "evaluation-key", - Arrays.asList(new Segment("license", "trial"), new Segment("region", "au"), new Segment("region", "us")), - 100 - )), - new byte[0] - ); - var subject = new OctopusContext(toggles); - - // A matching context value is present for each toggled segment - assertThat(subject.evaluate("testfeature", false, buildContext(Arrays.asList(Map.entry("license", "trial"), Map.entry("region", "us")))).getValue()) - .isTrue(); - - // A context value is present for each toggled segment, but it is not toggled on for one of the supplied values - assertThat(subject.evaluate("testfeature", false, buildContext(Arrays.asList(Map.entry("license", "trial"), Map.entry("region", "eu")))).getValue()) - .isFalse(); - - // A matching context value is present for each toggled segment, and an additional segment is present in the provided context values - assertThat(subject.evaluate("testfeature", false, buildContext(Arrays.asList(Map.entry("license", "trial"), Map.entry("region", "us"), Map.entry("language", "english")))).getValue()) - .isTrue(); - - // A context value is present for only one of the two toggled segments - assertThat(subject.evaluate("testfeature", false, buildContext(List.of(Map.entry("license", "trial")))).getValue()) - .isFalse(); + void matchesTheSlugWithoutRegardToCase() { + var context = contextWith(serverResolved("Feature-A", true)); - // No context values are present for the two toggled segments - assertThat(subject.evaluate("testfeature", true, buildContext(List.of(Map.entry("other", "segment")))).getValue()) - .isFalse(); - - // None specified - assertThat(subject.evaluate("testfeature", true, null).getValue()) - .isFalse(); + assertThat(context.evaluate("feature-a", null).getValue()).isTrue(); + assertThat(context.evaluate("FEATURE-A", null).getValue()).isTrue(); } @Test - void whenAFeatureIsToggledOnForASpecificSegment_ToleratesNullValuesInContext() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("testfeature", true, "evaluation-key", List.of(new Segment("license", "trial")), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - - // null value for the segment key does not match - var ctxNullLicense = new MutableContext(); - ctxNullLicense.add("license", (String) null); - assertThat(subject.evaluate("testfeature", false, ctxNullLicense).getValue()).isFalse(); + void picksTheRequestedFlagOutOfSeveral() { + var context = contextWith( + serverResolved("feature-a", true), + serverResolved("feature-b", false)); - assertThat(subject.evaluate("testfeature", false, buildContext(List.of(Map.entry("other", "segment")))).getValue()).isFalse(); - assertThat(subject.evaluate("testfeature", false, null).getValue()).isFalse(); + assertThat(context.evaluate("feature-a", null).getValue()).isTrue(); + assertThat(context.evaluate("feature-b", null).getValue()).isFalse(); } @Test - void whenTargetingKeyFallsWithinRolloutPercentage_AndFeatureIsNotToggledForSegments_ResolvesToTrue() { - // "evaluation-key:targeting-key" hashes to bucket 13, which is within the rollout of 13, so the feature is enabled - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 13)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(Collections.emptyList(), "targeting-key")).getValue()).isTrue(); + void anUnknownSlugThrowsFlagNotFound() { + assertThatThrownBy(() -> contextWith(serverResolved("feature-a", true)).evaluate("no-such-flag", null)) + .isInstanceOf(FlagNotFoundError.class) + .hasMessage("The slug provided did not match any of your Octopus Feature Flags. Please double check your slug and try again.") + .extracting(thrown -> ((FlagNotFoundError) thrown).getErrorCode()) + .isEqualTo(ErrorCode.FLAG_NOT_FOUND); } @Test - void whenTargetingKeyFallsOutsideRolloutPercentage_AndFeatureIsNotToggledForSegments_ResolvesToFalse() { - // "evaluation-key:targeting-key" hashes to bucket 13, which exceeds the rollout of 12, so the feature is disabled - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 12)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(Collections.emptyList(), "targeting-key")).getValue()).isFalse(); - } + void aNullFlagKeyThrowsFlagNotFoundRatherThanFailing() { + // The slug comes from the caller, so an unset config value arrives here as null. v3 answered + // FLAG_NOT_FOUND; anything else surfaces as ErrorCode.GENERAL with a raw exception message. + var context = contextWith(serverResolved("feature-a", true)); - @Test - void whenTargetingKeyFallsWithinRolloutPercentage_AndSegmentMatchesRequiredSegments_EvaluatesToTrue() { - // "evaluation-key:targeting-key" hashes to bucket 13, which is within the rollout of 13, and the segment license=trial matches - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", List.of(new Segment("license", "trial")), 13)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(List.of(Map.entry("license", "trial")), "targeting-key")).getValue()).isTrue(); + assertThatThrownBy(() -> context.evaluate(null, null)) + .isInstanceOf(FlagNotFoundError.class) + .extracting(thrown -> ((FlagNotFoundError) thrown).getErrorCode()) + .isEqualTo(ErrorCode.FLAG_NOT_FOUND); + assertThat(context.findEvaluationBySlug(null)).isNull(); } @Test - void whenTargetingKeyFallsWithinRolloutPercentage_AndSegmentValueDoesNotMatchRequiredSegment_EvaluatesToFalse() { - // "evaluation-key:targeting-key" hashes to bucket 13, which is within the rollout of 99, but the required - // segment license=enterprise does not match the provided license=trial, so the feature is disabled - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", List.of(new Segment("license", "enterprise")), 99)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(List.of(Map.entry("license", "trial")), "targeting-key")).getValue()).isFalse(); + void anEmptyResponseThrowsFlagNotFoundForEveryFlag() { + assertThatThrownBy(() -> OctopusContext.empty().evaluate("feature-a", null)) + .isInstanceOf(FlagNotFoundError.class); } @Test - void whenTargetingKeyFallsOutsideRolloutPercentage_AndSegmentValueDoesNotMatchRequiredSegment_EvaluatesToFalse() { - // "evaluation-key:targeting-key" hashes to bucket 13, which exceeds the rollout of 12, and the segment also does not match - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", List.of(new Segment("license", "enterprise")), 12)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(List.of(Map.entry("license", "trial")), "targeting-key")).getValue()).isFalse(); - } + void exposesTheContentHashOfTheResponseItHolds() { + byte[] contentHash = {0x01, 0x02}; - @Test - void whenNoTargetingKey_RolloutIsLessThanOneHundredPercent_ResolvesToFalse() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 99)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(Collections.emptyList(), null)).getValue()).isFalse(); + assertThat(new OctopusContext(new EvaluationResponse(List.of(), contentHash)).getContentHash()) + .isEqualTo(contentHash); + assertThat(OctopusContext.empty().getContentHash()).isEmpty(); } @Test - void whenNoTargetingKey_RolloutIsEqualToOneHundredPercent_ResolvesToTrue() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("test-feature", true, "evaluation-key", Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - assertThat(subject.evaluate("test-feature", false, buildContext(Collections.emptyList(), null)).getValue()).isTrue(); - } + void aNullEntryInTheResponseDoesNotCostTheOtherFlags() throws Exception { + // A malformed entry only fails its own flag, so a null alongside a well-formed flag must not take + // the lookup down with it. + List evaluations = OctopusObjectMapper.INSTANCE.readValue( + Contexts.json("[ null, { 'slug': 'feature-a', 'value': true, 'reason': 'Enabled.' } ]"), + new TypeReference>() {}); + var context = new OctopusContext(new EvaluationResponse(evaluations, new byte[0])); - @Test - void shouldThrowParseErrorWhenEnabledToggleIsMissingEvaluationKey() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("feature-a", true, null, Collections.emptyList(), 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - var ex = assertThrows(ParseError.class, () -> subject.evaluate("feature-a", false, null)); - assertThat(ex.getMessage()).contains("feature-a"); + assertThat(context.evaluate("feature-a", null).getValue()).isTrue(); + assertThatThrownBy(() -> context.evaluate("no-such-flag", null)) + .as("the null entry is skipped rather than matched").isInstanceOf(FlagNotFoundError.class); } @Test - void shouldThrowParseErrorWhenEnabledToggleIsMissingSegments() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("feature-b", true, "evaluation-key", null, 100)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - var ex = assertThrows(ParseError.class, () -> subject.evaluate("feature-b", false, null)); - assertThat(ex.getMessage()).contains("feature-b"); - } + void aResponseWithNoEvaluationsResolvesNothingRatherThanFailing() { + // Defence in depth: the client turns a null body into a failed fetch, so this shape should not + // reach the evaluator — but if it does, every flag is not-found rather than an NPE. + var context = new OctopusContext(new EvaluationResponse(null, new byte[0])); - @Test - void shouldThrowParseErrorWhenEnabledToggleIsMissingClientRolloutPercentage() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("feature-c", true, "evaluation-key", Collections.emptyList(), null)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - var ex = assertThrows(ParseError.class, () -> subject.evaluate("feature-c", false, null)); - assertThat(ex.getMessage()).contains("feature-c"); + assertThatThrownBy(() -> context.evaluate("feature-a", null)).isInstanceOf(FlagNotFoundError.class); + assertThat(context.findEvaluationBySlug("feature-a")).isNull(); } @Test - void shouldThrowParseErrorWhenEnabledToggleIsMissingAllClientEvaluationFields() { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("feature-d", true, null, null, null)), - new byte[0] - ); - var subject = new OctopusContext(toggles); - var ex = assertThrows(ParseError.class, () -> subject.evaluate("feature-d", true, null)); - assertThat(ex.getMessage()).contains("feature-d"); - } - - private EvaluationContext buildContext(List> entries) { - return buildContext(entries, null); - } - - private EvaluationContext buildContext(List> entries, String targetingKey) { - var context = new MutableContext(); - entries.forEach(entry -> context.add(entry.getKey(), entry.getValue())); - if (targetingKey != null) { - context.setTargetingKey(targetingKey); - } - return context; - } + void findsAnEvaluationBySlugWithoutEvaluatingIt() { + var context = contextWith(serverResolved("feature-a", true)); - // The vectors live in RolloutVectors, shared with the v4 rollout so both implementations are held - // to the same expected buckets. - @ParameterizedTest(name = "[{index}] ({0}, {1}) -> {2}") - @MethodSource("com.octopus.openfeature.provider.RolloutVectors#cases") - void getNormalizedNumberMatchesExpectedValue(String evaluationKey, String targetingKey, int expected) { - assertThat(OctopusContext.getNormalizedNumber(evaluationKey, targetingKey)).isEqualTo(expected); + assertThat(context.findEvaluationBySlug("FEATURE-A")).isNotNull(); + assertThat(context.findEvaluationBySlug("no-such-flag")).isNull(); } - } diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusProviderTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusProviderTests.java index 333c22e..fea82c5 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusProviderTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusProviderTests.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -19,11 +18,11 @@ class OctopusProviderTests { @BeforeEach void setup() throws Exception { - var toggles = new FeatureToggles( - List.of(new FeatureToggleEvaluation("feature-a", true, "key", Collections.emptyList(), 100)), + var response = new EvaluationResponse( + List.of(new ServerSideEvaluation("feature-a", true, "The flag is enabled for this environment.", null, null)), new byte[0] ); - var provider = new OctopusProvider(new FakeOctopusContextProvider(new OctopusContext(toggles))); + var provider = new OctopusProvider(new FakeOctopusContextProvider(new OctopusContext(response))); OpenFeatureAPI.getInstance().setProviderAndWait(provider); client = OpenFeatureAPI.getInstance().getClient(); } diff --git a/src/test/java/com/octopus/openfeature/provider/v4/PercentageByContextConditionTests.java b/src/test/java/com/octopus/openfeature/provider/PercentageByContextConditionTests.java similarity index 98% rename from src/test/java/com/octopus/openfeature/provider/v4/PercentageByContextConditionTests.java rename to src/test/java/com/octopus/openfeature/provider/PercentageByContextConditionTests.java index 09a8ad9..d5331f1 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/PercentageByContextConditionTests.java +++ b/src/test/java/com/octopus/openfeature/provider/PercentageByContextConditionTests.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.exceptions.ParseError; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/octopus/openfeature/provider/v4/PercentageRolloutTests.java b/src/test/java/com/octopus/openfeature/provider/PercentageRolloutTests.java similarity index 97% rename from src/test/java/com/octopus/openfeature/provider/v4/PercentageRolloutTests.java rename to src/test/java/com/octopus/openfeature/provider/PercentageRolloutTests.java index ba03dde..aaea725 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/PercentageRolloutTests.java +++ b/src/test/java/com/octopus/openfeature/provider/PercentageRolloutTests.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; diff --git a/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java b/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java index 2f8360c..97f7a8f 100644 --- a/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java +++ b/src/test/java/com/octopus/openfeature/provider/RolloutVectors.java @@ -14,16 +14,13 @@ * NOT modify the input arguments or expected values — doing so would mask a real divergence in * evaluation behaviour between libraries and defeat the purpose of these cases. * - *

Public and in this package so both the v3 path and the v4 rollout can consume one list: the two - * hold separate copies of the hash, and a single set of vectors is what stops them drifting apart. - * Test-only, so it is never published. */ -public final class RolloutVectors { +final class RolloutVectors { private RolloutVectors() { } - public static Stream cases() { + static Stream cases() { return Stream.of( Arguments.of("ef70b4c0-1773-44a3-9b95-f239ae97d9db", "780c4b16-a510-49fa-a2b2-bbd1c38dbe31", 48), Arguments.of("055665f0-fbb3-484e-8ef1-52125425b7b2", "6a33c211-5af8-4c34-ba82-4d79846cb045", 85), diff --git a/src/test/java/com/octopus/openfeature/provider/Server.java b/src/test/java/com/octopus/openfeature/provider/Server.java index ea2d431..d121d98 100644 --- a/src/test/java/com/octopus/openfeature/provider/Server.java +++ b/src/test/java/com/octopus/openfeature/provider/Server.java @@ -37,12 +37,12 @@ class Server { /** * Registers the given JSON as the response body for a new unique client token. * - * @param responseJson the JSON array that the toggle API would return + * @param responseJson the JSON array that the evaluations endpoint would return * @return the client identifier (Bearer token) to use in OctopusConfiguration */ String configure(String responseJson) { String token = UUID.randomUUID().toString(); - wireMock.stubFor(get(urlPathEqualTo("/api/toggles/evaluations/v3/")) + wireMock.stubFor(get(urlPathEqualTo("/api/feature-flags/evaluations/v4/")) .withHeader("Authorization", equalTo("Bearer " + token)) .willReturn(aResponse() .withStatus(200) diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/ServerSideEvaluationDeserializationTests.java similarity index 97% rename from src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java rename to src/test/java/com/octopus/openfeature/provider/ServerSideEvaluationDeserializationTests.java index cd207d8..fcd99a1 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ServerSideEvaluationDeserializationTests.java @@ -1,8 +1,7 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.octopus.openfeature.provider.TestObjectMapper; import org.junit.jupiter.api.Test; import java.io.InputStream; @@ -18,7 +17,7 @@ */ class ServerSideEvaluationDeserializationTests { - private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + private final ObjectMapper objectMapper = OctopusObjectMapper.INSTANCE; private InputStream resource(String name) { return getClass().getResourceAsStream(name); diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationTests.java b/src/test/java/com/octopus/openfeature/provider/ServerSideEvaluationTests.java similarity index 99% rename from src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationTests.java rename to src/test/java/com/octopus/openfeature/provider/ServerSideEvaluationTests.java index 8c818c2..3444253 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ServerSideEvaluationTests.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.exceptions.ParseError; diff --git a/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java b/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java index 1316a03..ba3f611 100644 --- a/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java @@ -21,6 +21,7 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -71,6 +72,14 @@ void evaluate(String fileName, String description, String responseJson, FixtureC assertThat(result.getErrorCode()) .as("[%s] %s → errorCode", fileName, description) .isEqualTo(mapErrorCode(testCase.expected.errorCode)); + + // Fixtures only state a reason where the specification pins one down, so an absent reason is not + // an assertion that the provider returned none. + if (testCase.expected.reason != null) { + assertThat(result.getReason()) + .as("[%s] %s → reason", fileName, description) + .isEqualTo(testCase.expected.reason); + } } static Stream fixtureTestCases() throws IOException { @@ -104,11 +113,16 @@ static Stream fixtureTestCases() throws IOException { } private static EvaluationContext buildContext(Map context) { - MutableContext ctx = new MutableContext(); - if (context != null) { - context.forEach(ctx::add); + if (context == null) { + return new MutableContext(); } - return ctx; + + // A null attribute is present in the context but holds no string, which is what a fixture means + // by a null value — not an attribute the caller left out. MutableContext.add() has no overload + // for a bare Value, so the attributes go in through the map constructor. + Map attributes = new LinkedHashMap<>(); + context.forEach((key, value) -> attributes.put(key, value == null ? new Value() : new Value(value))); + return new MutableContext(attributes); } private static ErrorCode mapErrorCode(String code) { @@ -155,6 +169,7 @@ static class FixtureConfiguration { static class FixtureExpected { public boolean value; + public String reason; public String errorCode; } diff --git a/src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java b/src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java deleted file mode 100644 index 0a34a97..0000000 --- a/src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.octopus.openfeature.provider; - -import com.fasterxml.jackson.databind.ObjectMapper; - -/** - * Exposes the provider's package-private {@link OctopusObjectMapper} to tests in other packages, so - * they can exercise deserialization with the same mapper the client uses in production rather than - * a re-declared copy that could drift from it. - * - *

Test-only: this lives in the test sources and is never published. - */ -public final class TestObjectMapper { - public static final ObjectMapper INSTANCE = OctopusObjectMapper.INSTANCE; - - private TestObjectMapper() { - } -} diff --git a/src/test/java/com/octopus/openfeature/provider/v4/UnknownConditionTests.java b/src/test/java/com/octopus/openfeature/provider/UnknownConditionTests.java similarity index 94% rename from src/test/java/com/octopus/openfeature/provider/v4/UnknownConditionTests.java rename to src/test/java/com/octopus/openfeature/provider/UnknownConditionTests.java index cf68f58..421cba1 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/UnknownConditionTests.java +++ b/src/test/java/com/octopus/openfeature/provider/UnknownConditionTests.java @@ -1,4 +1,4 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import dev.openfeature.sdk.exceptions.ParseError; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/octopus/openfeature/provider/v4/UnrecognisedConditionTests.java b/src/test/java/com/octopus/openfeature/provider/UnrecognisedConditionTests.java similarity index 92% rename from src/test/java/com/octopus/openfeature/provider/v4/UnrecognisedConditionTests.java rename to src/test/java/com/octopus/openfeature/provider/UnrecognisedConditionTests.java index 78e1631..a668aec 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/UnrecognisedConditionTests.java +++ b/src/test/java/com/octopus/openfeature/provider/UnrecognisedConditionTests.java @@ -1,7 +1,6 @@ -package com.octopus.openfeature.provider.v4; +package com.octopus.openfeature.provider; import com.fasterxml.jackson.databind.ObjectMapper; -import com.octopus.openfeature.provider.TestObjectMapper; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -14,7 +13,7 @@ */ class UnrecognisedConditionTests { - private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE; + private final ObjectMapper objectMapper = OctopusObjectMapper.INSTANCE; private ServerSideEvaluation flag(String singleQuotedJson) throws Exception { return objectMapper.readValue(Contexts.json(singleQuotedJson), ServerSideEvaluation.class); diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-not-one-of.json b/src/test/resources/com/octopus/openfeature/provider/condition-context-attribute-is-not-one-of.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-not-one-of.json rename to src/test/resources/com/octopus/openfeature/provider/condition-context-attribute-is-not-one-of.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-one-of.json b/src/test/resources/com/octopus/openfeature/provider/condition-context-attribute-is-one-of.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-one-of.json rename to src/test/resources/com/octopus/openfeature/provider/condition-context-attribute-is-one-of.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-list-mixed.json b/src/test/resources/com/octopus/openfeature/provider/condition-list-mixed.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/condition-list-mixed.json rename to src/test/resources/com/octopus/openfeature/provider/condition-list-mixed.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-missing-type.json b/src/test/resources/com/octopus/openfeature/provider/condition-missing-type.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/condition-missing-type.json rename to src/test/resources/com/octopus/openfeature/provider/condition-missing-type.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-percentage-by-context.json b/src/test/resources/com/octopus/openfeature/provider/condition-percentage-by-context.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/condition-percentage-by-context.json rename to src/test/resources/com/octopus/openfeature/provider/condition-percentage-by-context.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-unknown-type.json b/src/test/resources/com/octopus/openfeature/provider/condition-unknown-type.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/condition-unknown-type.json rename to src/test/resources/com/octopus/openfeature/provider/condition-unknown-type.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-deferred-to-client.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-deferred-to-client.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-deferred-to-client.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-deferred-to-client.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list-one-missing-slug.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-list-one-missing-slug.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list-one-missing-slug.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-list-one-missing-slug.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-list.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-list.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-missing-slug.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-missing-slug.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-missing-slug.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-missing-slug.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-server-resolved.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-server-resolved.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-server-resolved.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-server-resolved.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-extraneous-properties.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-with-extraneous-properties.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-extraneous-properties.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-with-extraneous-properties.json diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-unknown-condition.json b/src/test/resources/com/octopus/openfeature/provider/evaluation-with-unknown-condition.json similarity index 100% rename from src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-unknown-condition.json rename to src/test/resources/com/octopus/openfeature/provider/evaluation-with-unknown-condition.json