From 3860e274491dfd6084e087f8d09406c711715ec4 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 24 Jun 2026 13:01:22 -0400 Subject: [PATCH 1/2] chore(flagd): bump test-harness submodules to v3.8.0 (#1817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Todd Baert Signed-off-by: Marcin Wlazły --- providers/flagd/test-harness | 2 +- tools/flagd-api-testkit/test-harness | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/flagd/test-harness b/providers/flagd/test-harness index b507289c4..7575a1dc4 160000 --- a/providers/flagd/test-harness +++ b/providers/flagd/test-harness @@ -1 +1 @@ -Subproject commit b507289c45fca9c2d312c7231929e5b95eae62bb +Subproject commit 7575a1dc45f176e57e809748a712a555e9aa5d11 diff --git a/tools/flagd-api-testkit/test-harness b/tools/flagd-api-testkit/test-harness index b507289c4..7575a1dc4 160000 --- a/tools/flagd-api-testkit/test-harness +++ b/tools/flagd-api-testkit/test-harness @@ -1 +1 @@ -Subproject commit b507289c45fca9c2d312c7231929e5b95eae62bb +Subproject commit 7575a1dc45f176e57e809748a712a555e9aa5d11 From 4806e54645bf29b357e4046ad2ea4bfeddc980ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Wlaz=C5=82y?= Date: Tue, 30 Jun 2026 08:14:21 +0000 Subject: [PATCH 2/2] feat(codereadiness): add hook for version-based flag validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the codereadiness hook to control feature flag evaluation by comparing the application's current version with a required minimum version specified in the flag's metadata. If the comparator returns false the hook returns an error to trigger fallback to the default flag value. Signed-off-by: Marcin Wlazły --- .release-please-manifest.json | 1 + hooks/codereadiness/README.md | 95 ++++++ hooks/codereadiness/pom.xml | 31 ++ .../codereadiness/CodeReadinessHook.java | 106 +++++++ .../hooks/codereadiness/SemVerComparator.java | 29 ++ .../codereadiness/VersionComparator.java | 24 ++ .../codereadiness/CodeReadinessHookTest.java | 299 ++++++++++++++++++ .../codereadiness/SemVerComparatorTest.java | 61 ++++ pom.xml | 1 + release-please-config.json | 11 + 10 files changed, 658 insertions(+) create mode 100644 hooks/codereadiness/README.md create mode 100644 hooks/codereadiness/pom.xml create mode 100644 hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHook.java create mode 100644 hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparator.java create mode 100644 hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java create mode 100644 hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java create mode 100644 hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java diff --git a/.release-please-manifest.json b/.release-please-manifest.json index aeaaaad2d..68e55afa1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { "hooks/open-telemetry": "3.3.1", + "hooks/codereadiness": "0.1.0", "providers/flagd": "0.14.0", "providers/go-feature-flag": "1.1.2", "providers/flagsmith": "0.0.13", diff --git a/hooks/codereadiness/README.md b/hooks/codereadiness/README.md new file mode 100644 index 000000000..47d3e8deb --- /dev/null +++ b/hooks/codereadiness/README.md @@ -0,0 +1,95 @@ +# Code Readiness Hook + +The `codereadiness` hook allows controlling feature flag evaluation based on the version of the application code. +It does this by comparing the current application version with a required minimum version specified in the flag's metadata. +If the comparison fails (i.e., the application version is lower than the required version), the hook returns an error, causing the flag evaluation to resolve to its configured default value. + +## Installation + +```xml + + dev.openfeature.contrib.hooks + code-readiness-hook + 0.1.0 + +``` + +## Setup + +First, import the OpenFeature SDK and the code readiness hook: + +```java +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.contrib.hooks.codereadiness.CodeReadinessHook; +``` + +Then, configure the hook with the current version of the application code and register it: + +```java +// currentVersion is the current version of the code, which can be retrieved +// from environment variables, build properties, or configuration files. +String currentVersion = "1.0.0"; + +CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder(currentVersion).build(); + +// Register the hook globally at the OpenFeature API level +OpenFeatureAPI.getInstance().addHooks(codeReadinessHook); +``` + +## How It Works + +1. The hook runs during the **After** phase of flag evaluation. +2. It extracts the metadata associated with the evaluated flag. +3. It looks for a specific metadata key (by default, `minCodeVersion`). +4. If found, it compares the current application version against the required minimum version using the configured comparator (by default, a semver comparison). +5. If the current version is **lower** than the required version, it returns an error. This triggers the OpenFeature SDK's fallback mechanism, returning the flag's **default value** to the caller. + +## Options + +The behavior of the hook can be customized by passing options to the builder: + +### Strict Validation + +By default, the hook will **not** fail if the `minCodeVersion` metadata or the current application version is missing. To enforce version validation and return an error when these versions are missing, use `strictValidation(true)`. + +```java +CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("1.0.0") + .strictValidation(true) + .build(); +``` + +### Custom Metadata Key + +To configure the hook to look for a key other than the default `"minCodeVersion"` in the flag's metadata, use `metadataMinVerKey()`. + +```java +CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("1.0.0") + .metadataMinVerKey("customMetadataKey") + .build(); +``` + +### Custom Comparator + +By default, the hook performs a standard semver comparison. If the application uses a different versioning scheme (such as int-based versioning or custom build numbers), a custom comparison interface implementation can be provided using `comparator()`. The `VersionComparator` interface separates version string parsing from comparison logic. + +```java +import dev.openfeature.contrib.hooks.codereadiness.VersionComparator; + +// Example for int-based versioning +VersionComparator intComparator = new VersionComparator() { + + @Override + public Integer parse(String versionString) { + return Integer.parseInt(versionString); + } + + @Override + public boolean compare(Integer current, Integer required) { + return current >= required; + } +}; + +CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("15") + .comparator(intComparator) + .build(); +``` \ No newline at end of file diff --git a/hooks/codereadiness/pom.xml b/hooks/codereadiness/pom.xml new file mode 100644 index 000000000..40d5cfd9b --- /dev/null +++ b/hooks/codereadiness/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + dev.openfeature.contrib.hooks + code-readiness-hook + 0.1.0 + + code-readiness-hook + Code Readiness Hook + https://openfeature.dev + + + + ${groupId}.codereadiness + + + + + org.semver4j + semver4j + 5.8.0 + + + \ No newline at end of file diff --git a/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHook.java b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHook.java new file mode 100644 index 000000000..64e5f6d69 --- /dev/null +++ b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHook.java @@ -0,0 +1,106 @@ +package dev.openfeature.contrib.hooks.codereadiness; + +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.Map; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; + +/** + * Hook for controlling feature flag evaluation based on the application code version. + */ +@Slf4j +public final class CodeReadinessHook implements Hook { + + private static final String DEFAULT_MIN_CODE_VERSION_KEY = "minCodeVersion"; + private static final boolean DEFAULT_STRICT_VALIDATION = false; + + private final String currentVersion; + private final boolean strictValidation; + private final String metadataMinVerKey; + private final VersionComparator comparator; + private final Object parsedCurrentVersion; + + /** + * Builder for {@link CodeReadinessHook}. + */ + public static class Builder { + String currentVersion; + boolean strictValidation = DEFAULT_STRICT_VALIDATION; + String metadataMinVerKey = DEFAULT_MIN_CODE_VERSION_KEY; + VersionComparator comparator = new SemVerComparator(); + } + + @lombok.Builder(builderMethodName = "", builderClassName = "Builder") + CodeReadinessHook( + String currentVersion, + boolean strictValidation, + String metadataMinVerKey, + VersionComparator comparator) { + this.currentVersion = Objects.requireNonNull(currentVersion, "codereadiness: currentVersion cannot be null"); + this.strictValidation = strictValidation; + this.metadataMinVerKey = + Objects.requireNonNull(metadataMinVerKey, "codereadiness: metadataMinVerKey cannot be null"); + Objects.requireNonNull(comparator, "codereadiness: comparator cannot be null"); + this.comparator = (VersionComparator) comparator; + try { + this.parsedCurrentVersion = this.comparator.parse(currentVersion); + } catch (Exception err) { + throw new GeneralError( + String.format( + "current version: \"%s\" initialization failed: %s", currentVersion, err.getMessage()), + err); + } + } + + public static Builder builder(String currentVersion) { + return new Builder().currentVersion(currentVersion); + } + + @Override + public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { + ImmutableMetadata metadata = details != null ? details.getFlagMetadata() : null; + if (metadata == null || metadata.isEmpty()) { + if (strictValidation) { + throw new GeneralError(String.format("flag metadata is null for flag \"%s\"", ctx.getFlagKey())); + } + log.debug("flag metadata is null for flag \"{}\", skipping validation", ctx.getFlagKey()); + return; + } + String minCodeVersion = metadata.getString(metadataMinVerKey); + if (minCodeVersion == null || minCodeVersion.isEmpty()) { + if (strictValidation) { + throw new GeneralError(String.format( + "key \"%s\" missing or empty in flag's \"%s\" metadata", metadataMinVerKey, ctx.getFlagKey())); + } + log.debug( + "key \"{}\" missing or empty in flag's \"{}\", skipping validation", + metadataMinVerKey, + ctx.getFlagKey()); + return; + } + boolean isCodeReady; + try { + Object parsedMinVersion = comparator.parse(minCodeVersion); + isCodeReady = comparator.compare(this.parsedCurrentVersion, parsedMinVersion); + } catch (Exception err) { + if (strictValidation) { + throw new GeneralError( + String.format( + "current version: \"%s\" required minimum version: \"%s\" check failed: %s", + currentVersion, minCodeVersion, err.getMessage()), + err); + } + log.debug(String.format("invalid version values for flag \"%s\", skipping validation", ctx.getFlagKey())); + return; + } + if (!isCodeReady) { + throw new GeneralError(String.format( + "current version: \"%s\" required minimum version: \"%s\" check failed", + currentVersion, minCodeVersion)); + } + } +} diff --git a/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparator.java b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparator.java new file mode 100644 index 000000000..c7a273289 --- /dev/null +++ b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparator.java @@ -0,0 +1,29 @@ +package dev.openfeature.contrib.hooks.codereadiness; + +import java.util.Objects; +import org.semver4j.Semver; + +/** + * Default comparator implementation for standard Semantic Versioning (SemVer). + */ +public class SemVerComparator implements VersionComparator { + + public SemVerComparator() {} + + @Override + public Semver parse(String versionString) { + Objects.requireNonNull(versionString, "versionString cannot be null"); + Semver semver = Semver.parse(versionString); + if (semver == null) { + throw new IllegalArgumentException(String.format("invalid semver: \"%s\"", versionString)); + } + return semver; + } + + @Override + public boolean compare(Semver currentVersion, Semver minCodeVersion) { + Objects.requireNonNull(currentVersion, "currentVersion cannot be null"); + Objects.requireNonNull(minCodeVersion, "minCodeVersion cannot be null"); + return currentVersion.isGreaterThanOrEqualTo(minCodeVersion); + } +} diff --git a/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java new file mode 100644 index 000000000..c7ce6c20a --- /dev/null +++ b/hooks/codereadiness/src/main/java/dev/openfeature/contrib/hooks/codereadiness/VersionComparator.java @@ -0,0 +1,24 @@ +package dev.openfeature.contrib.hooks.codereadiness; + +/** + * Defines the contract for parsing version strings and comparing code version objects + * (current and minimum required version) according to specified rules. Used by {@link CodeReadinessHook}. + * + *

The {@link CodeReadinessHook} uses {@link SemVerComparator} by default for standard Semantic + * Versioning, but developers may implement this interface to support custom or non-standard + * versioning schemes. + * + * @param The domain object type representing a parsed version (e.g., Semver, LocalDate, Integer). + */ +public interface VersionComparator { + + /** + * Parse version string into domain object. + */ + T parse(String versionString) throws Exception; + + /** + * Compare current version with required version. + */ + boolean compare(T currentVersion, T minCodeVersion) throws Exception; +} diff --git a/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java new file mode 100644 index 000000000..c5c4372f6 --- /dev/null +++ b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/CodeReadinessHookTest.java @@ -0,0 +1,299 @@ +package dev.openfeature.contrib.hooks.codereadiness; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CodeReadinessHookTest { + + private final HookContext hookContext = mock(HookContext.class); + + @BeforeEach + void setUp() { + when(hookContext.getFlagKey()).thenReturn("testFlag"); + } + + @Test + @DisplayName("Should pass when current version is equal or greater than required metadata version") + void testValidVersionPasses() { + CodeReadinessHook hook = CodeReadinessHook.builder("1.5.0").build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.2.0"); + + assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw GeneralError when current version is less than required metadata version") + void testInvalidVersionThrowsGeneralError() { + CodeReadinessHook hook = CodeReadinessHook.builder("1.0.0").build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.2.0"); + + assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("current version: \"1.0.0\" required minimum version: \"1.2.0\" check failed"); + } + + @Test + @DisplayName("Should ignore missing metadata when strictValidation is false") + void testMissingMetadataIgnoredWhenValidationNotRequired() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(false).build(); + + assertThatCode(() -> hook.after(hookContext, null, Collections.emptyMap())) + .doesNotThrowAnyException(); + + FlagEvaluationDetails emptyMetadataDetails = FlagEvaluationDetails.builder() + .flagMetadata(ImmutableMetadata.builder().build()) + .build(); + assertThatCode(() -> hook.after(hookContext, emptyMetadataDetails, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw GeneralError when metadata is missing and strictValidation is true") + void testMissingMetadataThrowsWhenStrictValidation() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(true).build(); + + assertThatThrownBy(() -> hook.after(hookContext, null, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("flag metadata is null for flag \"testFlag\""); + + FlagEvaluationDetails emptyMetadataDetails = FlagEvaluationDetails.builder() + .flagMetadata(ImmutableMetadata.builder().build()) + .build(); + assertThatThrownBy(() -> hook.after(hookContext, emptyMetadataDetails, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("flag metadata is null for flag \"testFlag\""); + } + + @Test + @DisplayName("Should ignore missing minCodeVersion key when strictValidation is false") + void testMissingKeyIgnoredWhenStrictValidationNotRequired() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(false).build(); + FlagEvaluationDetails details = createDetailsWithMetadata("otherKey", "1.0.0"); + + assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw GeneralError when minCodeVersion key is missing and strictValidation is true") + void testMissingKeyThrowsWhenStrictValidation() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(true).build(); + FlagEvaluationDetails details = createDetailsWithMetadata("otherKey", "1.0.0"); + + assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("key \"minCodeVersion\" missing or empty in flag's \"testFlag\" metadata"); + } + + @Test + @DisplayName("Should use custom metadataMinVerKey when specified") + void testCustomMetadataMinVerKey() { + CodeReadinessHook hook = CodeReadinessHook.builder("2.0.0") + .metadataMinVerKey("customMinVersion") + .strictValidation(true) + .build(); + FlagEvaluationDetails details = createDetailsWithMetadata("customMinVersion", "1.5.0"); + + assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw GeneralError when metadata value is not a string and strictValidation is true") + void testNonStringMetadataValueThrows() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(true).build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", true); + + assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("key \"minCodeVersion\" missing or empty in flag's \"testFlag\" metadata"); + } + + @Test + @DisplayName("Should ignore non string metadata value when strictValidation is false") + void testNonStringMetadataValueIgnores() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(false).build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", true); + + assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should ignore empty minCodeVersion value when strictValidation is false") + void testEmptyVersionStringIgnoredWhenStrictValidationNotRequired() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(false).build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", ""); + + assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should throw GeneralError when minCodeVersion string is empty and strictValidation is true") + void testEmptyVersionStringThrowsWhenStrictValidation() { + CodeReadinessHook hook = + CodeReadinessHook.builder("1.0.0").strictValidation(true).build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", ""); + + assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("key \"minCodeVersion\" missing or empty in flag's \"testFlag\" metadata"); + } + + @Test + @DisplayName("Should throw NullPointerException when building hook with null arguments") + void testNullArgumentsThrowNpeAtBuildTime() { + assertThatThrownBy(() -> CodeReadinessHook.builder(null).build()) + .isInstanceOf(NullPointerException.class) + .hasMessage("codereadiness: currentVersion cannot be null"); + + assertThatThrownBy(() -> + CodeReadinessHook.builder("1.0.0").comparator(null).build()) + .isInstanceOf(NullPointerException.class) + .hasMessage("codereadiness: comparator cannot be null"); + + assertThatThrownBy(() -> CodeReadinessHook.builder("1.0.0") + .metadataMinVerKey(null) + .build()) + .isInstanceOf(NullPointerException.class) + .hasMessage("codereadiness: metadataMinVerKey cannot be null"); + } + + @Test + @DisplayName("Should use custom comparator when configured") + void testCustomComparator() throws Exception { + CodeReadinessHook hook = CodeReadinessHook.builder("10.0.0") + .comparator(new VersionComparator() { + @Override + public String parse(String s) { + return s; + } + + @Override + public boolean compare(String c, String m) { + return false; + } + }) + .build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.0.0"); + + assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("current version: \"10.0.0\" required minimum version: \"1.0.0\" check failed"); + } + + @Test + @DisplayName("Should wrap exception thrown by comparator into GeneralError") + void testComparatorExceptionWrappedInGeneralErrorStrictValidation() throws Exception { + CodeReadinessHook hook = CodeReadinessHook.builder("1.0.0") + .strictValidation(true) + .comparator(new VersionComparator() { + @Override + public String parse(String s) { + return s; + } + + @Override + public boolean compare(String c, String m) { + throw new RuntimeException("comparator error"); + } + }) + .build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.0.0"); + + assertThatThrownBy(() -> hook.after(hookContext, details, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage( + "current version: \"1.0.0\" required minimum version: \"1.0.0\" check failed: comparator error") + .hasCauseInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("Should skip validation when exception thrown by comparator and strictValidation is false") + void testComparatorExceptionNonStrictValidation() throws Exception { + CodeReadinessHook hook = CodeReadinessHook.builder("1.0.0") + .strictValidation(false) + .comparator(new VersionComparator() { + @Override + public String parse(String s) { + return s; + } + + @Override + public boolean compare(String c, String m) { + throw new RuntimeException("comparator error"); + } + }) + .build(); + FlagEvaluationDetails details = createDetailsWithMetadata("minCodeVersion", "1.0.0"); + + assertThatCode(() -> hook.after(hookContext, details, Collections.emptyMap())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("Should wrap exception thrown during comparator initialization into GeneralError") + void testComparatorInitExceptionWrappedInGeneralError() { + assertThatThrownBy(() -> CodeReadinessHook.builder("invalid-version").build()) + .isInstanceOf(GeneralError.class) + .hasMessageContaining("initialization failed"); + } + + @Test + @DisplayName("Should support generic custom comparator with domain object parsing") + void testGenericCustomDomainComparator() { + VersionComparator intComparator = new VersionComparator() { + @Override + public Integer parse(String versionString) { + return Integer.parseInt(versionString); + } + + @Override + public boolean compare(Integer currentVersion, Integer minCodeVersion) { + return currentVersion >= minCodeVersion; + } + }; + + CodeReadinessHook hook = + CodeReadinessHook.builder("15").comparator(intComparator).build(); + + FlagEvaluationDetails validDetails = createDetailsWithMetadata("minCodeVersion", "10"); + assertThatCode(() -> hook.after(hookContext, validDetails, Collections.emptyMap())) + .doesNotThrowAnyException(); + + FlagEvaluationDetails invalidDetails = createDetailsWithMetadata("minCodeVersion", "20"); + assertThatThrownBy(() -> hook.after(hookContext, invalidDetails, Collections.emptyMap())) + .isInstanceOf(GeneralError.class) + .hasMessage("current version: \"15\" required minimum version: \"20\" check failed"); + } + + private FlagEvaluationDetails createDetailsWithMetadata(String key, Object value) { + ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (value instanceof Boolean) { + builder.addBoolean(key, (Boolean) value); + } else { + builder.addString(key, value.toString()); + } + return FlagEvaluationDetails.builder().flagMetadata(builder.build()).build(); + } +} diff --git a/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java new file mode 100644 index 000000000..001e7eb5b --- /dev/null +++ b/hooks/codereadiness/src/test/java/dev/openfeature/contrib/hooks/codereadiness/SemVerComparatorTest.java @@ -0,0 +1,61 @@ +package dev.openfeature.contrib.hooks.codereadiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class SemVerComparatorTest { + + @ParameterizedTest + @CsvSource({ + "1.2.0, 1.1.0, true", + "1.1.0, 1.1.0, true", + "v1.2.0, 1.1.0, true", + "1.2.0, v1.1.0, true", + "v1.2.0, v1.1.0, true", + "2.0.0, 1.9.9, true", + "1.0.0, 1.1.0, false", + "v1.0.0, v1.1.0, false" + }) + @DisplayName("Should validate versions correctly according to SemVer rules") + void testVersionComparison(String currentVersion, String minCodeVersion, boolean expectedResult) throws Exception { + SemVerComparator comparator = new SemVerComparator(); + boolean result = comparator.compare(comparator.parse(currentVersion), comparator.parse(minCodeVersion)); + assertThat(result).isEqualTo(expectedResult); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when version is invalid semver in parse") + void testInvalidVersion() { + SemVerComparator comparator = new SemVerComparator(); + assertThatThrownBy(() -> comparator.parse("invalid-version")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid semver"); + } + + @Test + @DisplayName("Should throw NullPointerException when versionString is null in parse") + void testNullVersionString() { + SemVerComparator comparator = new SemVerComparator(); + assertThatThrownBy(() -> comparator.parse(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("versionString cannot be null"); + } + + @Test + @DisplayName("Should throw NullPointerException when arguments are null in compare") + void testNullArgumentsInCompare() throws Exception { + SemVerComparator comparator = new SemVerComparator(); + org.semver4j.Semver valid = comparator.parse("1.0.0"); + assertThatThrownBy(() -> comparator.compare(null, valid)) + .isInstanceOf(NullPointerException.class) + .hasMessage("currentVersion cannot be null"); + assertThatThrownBy(() -> comparator.compare(valid, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("minCodeVersion cannot be null"); + } +} diff --git a/pom.xml b/pom.xml index 1667517cd..29963bb06 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,7 @@ tools/flagd-api tools/flagd-core hooks/open-telemetry + hooks/codereadiness tools/junit-openfeature providers/flagd providers/flagsmith diff --git a/release-please-config.json b/release-please-config.json index 7cb16289d..d3281c2d5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -161,6 +161,17 @@ "README.md" ] }, + "hooks/codereadiness": { + "package-name": "dev.openfeature.contrib.hooks.codereadiness", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "tools/junit-openfeature": { "package-name": "dev.openfeature.contrib.tools.junitopenfeature", "release-type": "simple",