Skip to content

Commit 07126e5

Browse files
committed
feat(codereadiness): add hook for version-based flag validation
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.
1 parent c5efaff commit 07126e5

10 files changed

Lines changed: 565 additions & 0 deletions

File tree

.release-please-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"hooks/open-telemetry": "3.3.1",
3+
"hooks/codereadiness": "0.1.0",
34
"providers/flagd": "0.14.0",
45
"providers/go-feature-flag": "1.1.2",
56
"providers/flagsmith": "0.0.13",

hooks/codereadiness/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Code Readiness Hook
2+
3+
The `codereadiness` hook allows controlling feature flag evaluation based on the version of the application code.
4+
It does this by comparing the current application version with a required minimum version specified in the flag's metadata.
5+
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.
6+
7+
## Installation
8+
9+
```xml
10+
<dependency>
11+
<groupId>dev.openfeature.contrib.hooks</groupId>
12+
<artifactId>code-readiness-hook</artifactId>
13+
<version>0.1.0</version>
14+
</dependency>
15+
```
16+
17+
## Setup
18+
19+
First, import the OpenFeature SDK and the code readiness hook:
20+
21+
```java
22+
import dev.openfeature.sdk.OpenFeatureAPI;
23+
import dev.openfeature.contrib.hooks.codereadiness.CodeReadinessHook;
24+
```
25+
26+
Then, configure the hook with the current version of the application code and register it:
27+
28+
```java
29+
// currentVersion is the current version of the code, which can be retrieved
30+
// from environment variables, build properties, or configuration files.
31+
String currentVersion = "1.0.0";
32+
33+
CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder(currentVersion).build();
34+
35+
// Register the hook globally at the OpenFeature API level
36+
OpenFeatureAPI.getInstance().addHooks(codeReadinessHook);
37+
```
38+
39+
## How It Works
40+
41+
1. The hook runs during the **After** phase of flag evaluation.
42+
2. It extracts the metadata associated with the evaluated flag.
43+
3. It looks for a specific metadata key (by default, `minCodeVersion`).
44+
4. If found, it compares the current application version against the required minimum version using the configured comparator (by default, a semver comparison).
45+
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.
46+
47+
## Options
48+
49+
The behavior of the hook can be customized by passing options to the builder:
50+
51+
### Strict Validation
52+
53+
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)`.
54+
55+
```java
56+
CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("1.0.0")
57+
.strictValidation(true)
58+
.build();
59+
```
60+
61+
### Custom Metadata Key
62+
63+
To configure the hook to look for a key other than the default `"minCodeVersion"` in the flag's metadata, use `metadataMinVerKey()`.
64+
65+
```java
66+
CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("1.0.0")
67+
.metadataMinVerKey("customMetadataKey")
68+
.build();
69+
```
70+
71+
### Custom Comparator
72+
73+
By default, the hook performs a standard semver comparison. If the application uses a different versioning scheme (such as date-based versioning, revision numbers, or custom build numbers), a custom comparison interface implementation can be provided using `comparator()`.
74+
75+
```java
76+
import dev.openfeature.contrib.hooks.codereadiness.VersionComparator;
77+
78+
VersionComparator customComparator = (current, required) -> {
79+
// Custom comparison logic: return true if current is ready/sufficient,
80+
// or false if current is lower than required.
81+
return current.compareTo(required) >= 0;
82+
};
83+
84+
CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("2026.06.30")
85+
.comparator(customComparator)
86+
.build();
87+
```

hooks/codereadiness/pom.xml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<parent>
6+
<groupId>dev.openfeature.contrib</groupId>
7+
<artifactId>parent</artifactId>
8+
<version>[1.0,2.0)</version>
9+
<relativePath>../../pom.xml</relativePath>
10+
</parent>
11+
<groupId>dev.openfeature.contrib.hooks</groupId>
12+
<artifactId>code-readiness-hook</artifactId>
13+
<version>0.1.0</version> <!--x-release-please-version -->
14+
15+
<name>code-readiness-hook</name>
16+
<description>Code Readiness Hook</description>
17+
<url>https://openfeature.dev</url>
18+
19+
<properties>
20+
<!-- override module name defined in parent ("-" is not allowed) -->
21+
<module-name>${groupId}.codereadiness</module-name>
22+
</properties>
23+
24+
<dependencies>
25+
<dependency>
26+
<groupId>org.semver4j</groupId>
27+
<artifactId>semver4j</artifactId>
28+
<version>5.8.0</version>
29+
</dependency>
30+
</dependencies>
31+
</project>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package dev.openfeature.contrib.hooks.codereadiness;
2+
3+
import dev.openfeature.sdk.FlagEvaluationDetails;
4+
import dev.openfeature.sdk.Hook;
5+
import dev.openfeature.sdk.HookContext;
6+
import dev.openfeature.sdk.ImmutableMetadata;
7+
import dev.openfeature.sdk.exceptions.GeneralError;
8+
9+
import java.util.Map;
10+
import java.util.Objects;
11+
12+
import lombok.Builder;
13+
import lombok.extern.slf4j.Slf4j;
14+
15+
@Builder(builderMethodName = "", builderClassName = "Builder")
16+
@Slf4j
17+
public class CodeReadinessHook implements Hook {
18+
19+
private static final String DEFAULT_MIN_CODE_VERSION_KEY = "minCodeVersion";
20+
private static final boolean DEFAULT_STRICT_VALIDATION = false;
21+
private static final VersionComparator DEFAULT_VERSION_COMPARATOR = new SemVerComparator();
22+
23+
private final String currentVersion;
24+
25+
@Builder.Default
26+
private final boolean strictValidation = DEFAULT_STRICT_VALIDATION;
27+
28+
@Builder.Default
29+
private final String metadataMinVerKey = DEFAULT_MIN_CODE_VERSION_KEY;
30+
31+
@Builder.Default
32+
private final VersionComparator comparator = DEFAULT_VERSION_COMPARATOR;
33+
34+
CodeReadinessHook(String currentVersion, boolean strictValidation, String metadataMinVerKey, VersionComparator comparator) {
35+
this.currentVersion = Objects.requireNonNull(currentVersion, "codereadiness: currentVersion cannot be null");
36+
this.strictValidation = strictValidation;
37+
this.metadataMinVerKey = Objects.requireNonNull(metadataMinVerKey, "codereadiness: metadataMinVerKey cannot be null");
38+
this.comparator = Objects.requireNonNull(comparator, "codereadiness: comparator cannot be null");
39+
}
40+
41+
public static Builder builder(String currentVersion) {
42+
Objects.requireNonNull(currentVersion, "codereadiness: currentVersion cannot be null");
43+
return new Builder().currentVersion(currentVersion);
44+
}
45+
46+
@Override
47+
public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) {
48+
ImmutableMetadata metadata = details != null ? details.getFlagMetadata() : null;
49+
if (metadata == null || metadata.isEmpty()) {
50+
if (strictValidation) {
51+
throw new GeneralError(String.format("flag metadata is null for flag \"%s\"", ctx.getFlagKey()));
52+
}
53+
log.debug("flag metadata is null for flag \"{}\", skipping validation", ctx.getFlagKey());
54+
return;
55+
}
56+
Object minVerObj = metadata.asUnmodifiableMap().get(metadataMinVerKey);
57+
if (minVerObj == null) {
58+
if (strictValidation) {
59+
throw new GeneralError(String.format("key \"%s\" missing in flag's \"%s\" metadata", metadataMinVerKey, ctx.getFlagKey()));
60+
}
61+
log.debug("key \"{}\" missing in flag's \"{}\" metadata, skipping validation", metadataMinVerKey, ctx.getFlagKey());
62+
return;
63+
}
64+
if (!(minVerObj instanceof String)) {
65+
if (strictValidation) {
66+
throw new GeneralError(String.format("metadata \"%s\" is not a string for flag \"%s\"", metadataMinVerKey, ctx.getFlagKey()));
67+
}
68+
log.debug("metadata \"{}\" is not a string for flag \"{}\", skipping validation", metadataMinVerKey, ctx.getFlagKey());
69+
return;
70+
}
71+
String minCodeVersion = (String) minVerObj;
72+
if (minCodeVersion.isEmpty()) {
73+
if (strictValidation) {
74+
throw new GeneralError(String.format("metadata \"%s\" is empty for flag \"%s\"", metadataMinVerKey, ctx.getFlagKey()));
75+
}
76+
log.debug("metadata \"{}\" is empty for flag \"{}\", skipping validation", metadataMinVerKey, ctx.getFlagKey());
77+
return;
78+
}
79+
boolean isCodeReady;
80+
try {
81+
isCodeReady = comparator.compare(currentVersion, minCodeVersion);
82+
} catch (Exception err) {
83+
throw new GeneralError(
84+
String.format(
85+
"current version: \"%s\" required minimum version: \"%s\" check failed: %s",
86+
currentVersion, minCodeVersion, err.getMessage()),
87+
err);
88+
}
89+
if (!isCodeReady) {
90+
throw new GeneralError(String.format("current version: \"%s\" required minimum version: \"%s\" check failed", currentVersion, minCodeVersion));
91+
}
92+
}
93+
94+
95+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dev.openfeature.contrib.hooks.codereadiness;
2+
3+
import dev.openfeature.sdk.FlagEvaluationDetails;
4+
import dev.openfeature.sdk.Hook;
5+
import dev.openfeature.sdk.HookContext;
6+
import dev.openfeature.sdk.ImmutableMetadata;
7+
import dev.openfeature.sdk.exceptions.GeneralError;
8+
import java.util.Map;
9+
import org.semver4j.Semver;
10+
11+
public class SemVerComparator implements VersionComparator {
12+
@Override
13+
public boolean compare(String currentVersion, String minCodeVersion) throws Exception {
14+
String currentFormatted = currentVersion != null && currentVersion.startsWith("v") ? currentVersion : "v" + currentVersion;
15+
String minCodeVersionFormatted = minCodeVersion != null && minCodeVersion.startsWith("v") ? minCodeVersion : "v" + minCodeVersion;
16+
17+
Semver currentSemver = Semver.parse(currentFormatted);
18+
if (currentSemver == null) {
19+
throw new IllegalArgumentException(String.format("invalid current semver: \"%s\"", currentVersion));
20+
}
21+
22+
Semver minCodeVersionSemver = Semver.parse(minCodeVersionFormatted);
23+
if (minCodeVersionSemver == null) {
24+
throw new IllegalArgumentException(String.format("invalid min code version semver: \"%s\"", minCodeVersion));
25+
}
26+
27+
return currentSemver.isGreaterThan(minCodeVersionSemver) || currentSemver.isEqualTo(minCodeVersionSemver);
28+
}
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package dev.openfeature.contrib.hooks.codereadiness;
2+
3+
/**
4+
* Defines the contract for comparing code version strings (current and minimum required
5+
* version) according to specified rules. Used by {@link CodeReadinessHook}.
6+
*
7+
* <p>The {@link CodeReadinessHook} uses {@link SemVerComparator} by default for standard Semantic
8+
* Versioning, but developers may implement this interface to support custom or non-standard
9+
* versioning schemes.
10+
*/
11+
public interface VersionComparator {
12+
/**
13+
* Compare current version with required version.
14+
*
15+
* @param currentVersion of the application
16+
* @param minCodeVersion required minimum version
17+
* @return true if currentVersion is greater than or equal to minCodeVersion
18+
* @throws Exception if there is a parsing error
19+
*/
20+
boolean compare(String currentVersion, String minCodeVersion) throws Exception;
21+
}

0 commit comments

Comments
 (0)