Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
87 changes: 87 additions & 0 deletions hooks/codereadiness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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
<dependency>
<groupId>dev.openfeature.contrib.hooks</groupId>
<artifactId>code-readiness-hook</artifactId>
<version>0.1.0</version>
</dependency>
```

## 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 date-based versioning, revision numbers, or custom build numbers), a custom comparison interface implementation can be provided using `comparator()`.

```java
import dev.openfeature.contrib.hooks.codereadiness.VersionComparator;

VersionComparator customComparator = (current, required) -> {
// Custom comparison logic: return true if current is ready/sufficient,
// or false if current is lower than required.
return current.compareTo(required) >= 0;
};

CodeReadinessHook codeReadinessHook = CodeReadinessHook.builder("2026.06.30")
.comparator(customComparator)
.build();
```
31 changes: 31 additions & 0 deletions hooks/codereadiness/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.openfeature.contrib</groupId>
<artifactId>parent</artifactId>
<version>[1.0,2.0)</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<groupId>dev.openfeature.contrib.hooks</groupId>
<artifactId>code-readiness-hook</artifactId>
<version>0.1.0</version> <!--x-release-please-version -->

<name>code-readiness-hook</name>
<description>Code Readiness Hook</description>
<url>https://openfeature.dev</url>

<properties>
<!-- override module name defined in parent ("-" is not allowed) -->
<module-name>${groupId}.codereadiness</module-name>
</properties>

<dependencies>
<dependency>
<groupId>org.semver4j</groupId>
<artifactId>semver4j</artifactId>
<version>5.8.0</version>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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.Builder;
import lombok.extern.slf4j.Slf4j;

@Builder(builderMethodName = "", builderClassName = "Builder")
@Slf4j
public class CodeReadinessHook implements Hook {

private static final String DEFAULT_MIN_CODE_VERSION_KEY = "minCodeVersion";
private static final boolean DEFAULT_STRICT_VALIDATION = false;
private static final VersionComparator DEFAULT_VERSION_COMPARATOR = new SemVerComparator();

private final String currentVersion;

@Builder.Default
private final boolean strictValidation = DEFAULT_STRICT_VALIDATION;

@Builder.Default
private final String metadataMinVerKey = DEFAULT_MIN_CODE_VERSION_KEY;

@Builder.Default
private final VersionComparator comparator = DEFAULT_VERSION_COMPARATOR;

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");
this.comparator = Objects.requireNonNull(comparator, "codereadiness: comparator cannot be null");
}

public static Builder builder(String currentVersion) {
Objects.requireNonNull(currentVersion, "codereadiness: currentVersion cannot be null");
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;
}
Object minVerObj = metadata.asUnmodifiableMap().get(metadataMinVerKey);
if (minVerObj == null) {
if (strictValidation) {
throw new GeneralError(String.format("key \"%s\" missing in flag's \"%s\" metadata", metadataMinVerKey, ctx.getFlagKey()));
}
log.debug("key \"{}\" missing in flag's \"{}\" metadata, skipping validation", metadataMinVerKey, ctx.getFlagKey());
return;
}
if (!(minVerObj instanceof String)) {
if (strictValidation) {
throw new GeneralError(String.format("metadata \"%s\" is not a string for flag \"%s\"", metadataMinVerKey, ctx.getFlagKey()));
}
log.debug("metadata \"{}\" is not a string for flag \"{}\", skipping validation", metadataMinVerKey, ctx.getFlagKey());
return;
}
String minCodeVersion = (String) minVerObj;
if (minCodeVersion.isEmpty()) {
if (strictValidation) {
throw new GeneralError(String.format("metadata \"%s\" is empty for flag \"%s\"", metadataMinVerKey, ctx.getFlagKey()));
}
log.debug("metadata \"{}\" is empty for flag \"{}\", skipping validation", metadataMinVerKey, ctx.getFlagKey());
return;
}
boolean isCodeReady;
try {
isCodeReady = comparator.compare(currentVersion, minCodeVersion);
} catch (Exception err) {
throw new GeneralError(
String.format(
"current version: \"%s\" required minimum version: \"%s\" check failed: %s",
currentVersion, minCodeVersion, err.getMessage()),
err);
}
if (!isCodeReady) {
throw new GeneralError(String.format("current version: \"%s\" required minimum version: \"%s\" check failed", currentVersion, minCodeVersion));
}
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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 org.semver4j.Semver;

public class SemVerComparator implements VersionComparator {
@Override
public boolean compare(String currentVersion, String minCodeVersion) throws Exception {
String currentFormatted = currentVersion != null && currentVersion.startsWith("v") ? currentVersion : "v" + currentVersion;
String minCodeVersionFormatted = minCodeVersion != null && minCodeVersion.startsWith("v") ? minCodeVersion : "v" + minCodeVersion;

Semver currentSemver = Semver.parse(currentFormatted);
if (currentSemver == null) {
throw new IllegalArgumentException(String.format("invalid current semver: \"%s\"", currentVersion));
}

Semver minCodeVersionSemver = Semver.parse(minCodeVersionFormatted);
if (minCodeVersionSemver == null) {
throw new IllegalArgumentException(String.format("invalid min code version semver: \"%s\"", minCodeVersion));
}

return currentSemver.isGreaterThan(minCodeVersionSemver) || currentSemver.isEqualTo(minCodeVersionSemver);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dev.openfeature.contrib.hooks.codereadiness;

/**
* Defines the contract for comparing code version strings (current and minimum required
* version) according to specified rules. Used by {@link CodeReadinessHook}.
*
* <p>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.
*/
public interface VersionComparator {
Comment thread
marcin11858 marked this conversation as resolved.
/**
* Compare current version with required version.
*
* @param currentVersion of the application
* @param minCodeVersion required minimum version
* @return true if currentVersion is greater than or equal to minCodeVersion
* @throws Exception if there is a parsing error
*/
boolean compare(String currentVersion, String minCodeVersion) throws Exception;
}
Loading
Loading