Skip to content
Open
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.2.1",
"providers/flagsmith": "0.0.13",
Expand Down
95 changes: 95 additions & 0 deletions hooks/codereadiness/README.md
Original file line number Diff line number Diff line change
@@ -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
<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 int-based versioning or custom build numbers), a custom comparison interface implementation can be provided using `comparator()`. The `VersionComparator<T>` interface separates version string parsing from comparison logic.

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

// Example for int-based versioning
VersionComparator<Integer> intComparator = new VersionComparator<Integer>() {

@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();
```
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,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;
Comment thread
marcin11858 marked this conversation as resolved.
private final boolean strictValidation;
private final String metadataMinVerKey;
private final VersionComparator<Object> 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<Object>) 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) {
Comment thread
marcin11858 marked this conversation as resolved.
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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Semver> {

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);
}
}
Original file line number Diff line number Diff line change
@@ -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}.
*
* <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.
*
* @param <T> The domain object type representing a parsed version (e.g., Semver, LocalDate, Integer).
*/
public interface VersionComparator<T> {

/**
* 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;
Comment thread
chrfwow marked this conversation as resolved.
}
Loading
Loading