Skip to content
Merged
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
24 changes: 20 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,8 @@ lazy val commonSettings = Seq(
)
) ++ crossVersionSourceDirs

// Override vulnerable transitive jackson-core (GHSA-72hv-8253-57qq)
ThisBuild / dependencyOverrides += "com.fasterxml.jackson.core" % "jackson-core" % "2.18.6"

lazy val root = (project in file("."))
.aggregate(core, testkit, extras)
.aggregate(core, testkit, extras, ofrep)
.settings(
name := "zio-openfeature",
publish / skip := true,
Expand Down Expand Up @@ -127,6 +124,25 @@ lazy val extras = (project in file("extras"))
)
)

// OFREP module - OpenFeature Remote Evaluation Protocol provider.
// Kept separate from `extras` so callers who only want HOCON/env vars don't pull in the OFREP contrib provider's
// transitive HTTP-client stack (Jackson, Guava, Commons Validator, SLF4J).
lazy val ofrep = (project in file("ofrep"))
.dependsOn(core)
.settings(
name := "zio-openfeature-ofrep",
commonSettings,
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % zioVersion,
"dev.openfeature.contrib.providers" % "ofrep" % "0.0.1",
"org.wiremock" % "wiremock" % "3.10.0" % Test
),
// GHSA-72hv-8253-57qq (jackson-core <2.18.0) is patched in 2.18+; the OFREP contrib provider pulls 2.21.2, so we
// override jackson-core to that version to avoid a split Jackson family (core 2.18 / databind 2.21 → runtime
// NoSuchMethodError). Scoped to this module so other modules aren't dragged into Jackson alignment they don't need.
dependencyOverrides += "com.fasterxml.jackson.core" % "jackson-core" % "2.21.2"
)

// Testkit module - testing utilities
lazy val testkit = (project in file("testkit"))
.dependsOn(core)
Expand Down
80 changes: 80 additions & 0 deletions docs/extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ The `zio-openfeature-extras` module provides built-in providers for common use c
libraryDependencies += "io.github.etacassiopeia" %% "zio-openfeature-extras" % "<version>"
```

OFREP (HTTP-based remote evaluation) lives in its own module so its HTTP-client transitive deps (Jackson, Guava, etc.) don't get pulled in for users who only need HOCON/env-var providers. See the [OFREP Provider](#ofrep-provider) section below for the dependency snippet.

---

## HOCON Provider
Expand Down Expand Up @@ -147,6 +149,84 @@ val provider = EnvVarProvider.withLookup(testEnv.get)

---

## OFREP Provider

Evaluates flags via the [OpenFeature Remote Evaluation Protocol](https://github.com/open-feature/protocol) (OFREP) — the standard HTTP protocol for vendor-neutral remote flag evaluation. Use this when your flags are served by an OFREP-compatible backend (flagd, OFREP relays, or any compliant server) and you want a vendor-agnostic client.

`OFREPProvider` is a small Scala-friendly factory over the OpenFeature Java SDK's `dev.openfeature.contrib.providers.ofrep.OfrepProvider`. The Java provider handles HTTP requests, polling, caching, and state transitions; the Scala factory just sugars the construction.

> **Note:** The underlying contrib provider is at version `0.0.1` — the API may evolve as OFREP itself matures. Pin the dependency deliberately.

### Dependency

OFREP lives in its own module so that callers who only want HOCON / env-var providers don't pull in the HTTP-client transitive stack (Jackson, Guava, Commons Validator, SLF4J).

```scala
libraryDependencies += "io.github.etacassiopeia" %% "zio-openfeature-ofrep" % "<version>"
```

### Usage

```scala
import zio.*
import zio.openfeature.*
import zio.openfeature.ofrep.OFREPProvider

// Common case: point at an OFREP endpoint
val layer = FeatureFlags.fromProvider(OFREPProvider("https://flags.example.com"))

// Default endpoint (http://localhost:8016)
val localLayer = FeatureFlags.fromProvider(OFREPProvider())
```

For full configuration (auth headers, timeouts, custom executor), use `fromOptions`:

```scala
import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions
import scala.jdk.CollectionConverters._
import java.time.Duration as JDuration

val options = OfrepProviderOptions.builder()
.baseUrl("https://flags.example.com")
.requestTimeout(JDuration.ofSeconds(5))
.connectTimeout(JDuration.ofSeconds(2))
.headers(Map("Authorization" -> "Bearer my-token").asJava)
.build()

val layer = FeatureFlags.fromProvider(OFREPProvider.fromOptions(options))
```

The factories return a `dev.openfeature.contrib.providers.ofrep.OfrepProvider` directly, so you can pass them to any `FeatureFlags.fromProvider*` builder without further wrapping.

### Configuration options

The full set of options is exposed by the Java SDK's `OfrepProviderOptions` builder:

| Option | Default | Description |
|:-------|:--------|:------------|
| `baseUrl` | `http://localhost:8016` | OFREP server endpoint |
| `requestTimeout` | `10s` | Per-request HTTP timeout |
| `connectTimeout` | `10s` | TCP connect timeout |
| `headers` | empty | Static headers applied to every request (e.g., bearer token) |
| `proxySelector` | system default | Custom `java.net.ProxySelector` |
| `executor` | fixed pool of 5 | Executor for HTTP work |

### Async initialization

Like any other provider, the OFREP provider works with `fromProviderAsync` for non-blocking startup:

```scala
val layer = FeatureFlags.fromProviderAsync(OFREPProvider("https://flags.example.com"))
```

Evaluations fail with `ProviderNotReady` until the provider has fetched its initial flag set.

### Transitive dependencies

Adding `zio-openfeature-ofrep` pulls in Jackson (core/databind/jsr310), Guava, Commons Validator, and SLF4J via the contrib provider. This is intentionally isolated from the `extras` module so projects without OFREP keep their dependency footprint small.

---

## Circuit Breaker Provider

A decorator that wraps any provider with circuit breaker logic for fast failover. When the delegate provider fails repeatedly or becomes unhealthy, the circuit opens and evaluations fail immediately (< 1ms) — enabling instant fallback when composed with `MultiProvider` and `MultiProviderStrategy.firstSuccessful`.
Expand Down
12 changes: 10 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,23 @@ program.provide(Scope.default >>> layer)

### Built-in Providers

The `zio-openfeature-extras` module includes providers for common use cases — no external vendor required:
This repo ships providers for common use cases — no external vendor required.

The `zio-openfeature-extras` module bundles the lightweight providers:

| Provider | Use case |
|:---------|:---------|
| `HoconProvider` | Read flags from `application.conf` (Typesafe Config) |
| `EnvVarProvider` | Read flags from environment variables |
| `CachingProvider` | Wrap any provider with zio-cache backed evaluation caching |

See [Extras]({{ site.baseurl }}/extras) for details.
The `zio-openfeature-ofrep` module is shipped separately so its HTTP-client transitive stack (Jackson, Guava) only loads if you actually use OFREP:

| Provider | Use case |
|:---------|:---------|
| `OFREPProvider` | Evaluate flags via the OpenFeature Remote Evaluation Protocol (HTTP) |

See [Extras]({{ site.baseurl }}/extras) for details on all of the above.

---

Expand Down
39 changes: 39 additions & 0 deletions ofrep/src/main/scala/zio/openfeature/ofrep/OFREPProvider.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package zio.openfeature.ofrep

import dev.openfeature.contrib.providers.ofrep.{OfrepProvider, OfrepProviderOptions}

/** Scala-friendly factories for the OpenFeature Java SDK's OFREP contrib provider
* ([[dev.openfeature.contrib.providers.ofrep.OfrepProvider]]).
*
* OFREP (OpenFeature Remote Evaluation Protocol) is the standard HTTP protocol for vendor-neutral remote flag
* evaluation. The factories here just sugar over the contrib provider's static constructors; the returned value is a
* plain `OfrepProvider` (a `FeatureProvider`) that you pass to `FeatureFlags.fromProvider` or
* `FeatureFlags.fromProviderAsync` like any other provider.
*
* '''Experimental:''' the underlying contrib provider artifact is at version 0.0.1. The OFREP protocol itself is
* pre-1.0; both the wire format and this Scala facade may evolve in breaking ways. Pin the dependency deliberately.
*
* @see
* https://github.com/open-feature/protocol for the OFREP spec
* @see
* https://github.com/open-feature/java-sdk-contrib/tree/main/providers/ofrep for the underlying implementation
*/
object OFREPProvider {

/** Create an OFREP provider with the contrib provider's built-in default options (baseUrl defaults to whatever the
* contrib library declares — currently `http://localhost:8016`). Delegating to the contrib zero-arg constructor
* means we don't have to mirror its default URL here.
*/
def apply(): OfrepProvider =
OfrepProvider.constructProvider()

/** Create an OFREP provider pointed at a specific endpoint, otherwise using contrib defaults. */
def apply(baseUrl: String): OfrepProvider =
OfrepProvider.constructProvider(OfrepProviderOptions.builder().baseUrl(baseUrl).build())

/** Create an OFREP provider with a fully configured [[OfrepProviderOptions]] (auth headers, timeouts, executor,
* etc.).
*/
def fromOptions(options: OfrepProviderOptions): OfrepProvider =
OfrepProvider.constructProvider(options)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package zio.openfeature.ofrep

import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.WireMock._
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import dev.openfeature.sdk.{ErrorCode, ImmutableContext, MutableContext, Value}
import zio.test._

/** End-to-end tests against a WireMock-backed OFREP server. Verifies that the OFREP contrib provider's wire calls match
* the OFREP protocol and that responses round-trip correctly through `OFREPProvider`'s factories.
*
* Each test owns its own WireMock instance for isolation; the small per-test startup cost is paid back in
* test-independence. Tests run sequentially (`TestAspect.sequential`) because the contrib provider 0.0.1's internal
* executor handling is order-sensitive — see PR #119 review notes.
*/
object OFREPProviderIntegrationSpec extends ZIOSpecDefault {

private val emptyContext = new ImmutableContext()

private def withMockServer[A](body: WireMockServer => A): A = {
val server = new WireMockServer(WireMockConfiguration.options().dynamicPort())
server.start()
try body(server)
finally server.stop()
}

private def baseUrl(server: WireMockServer): String =
s"http://localhost:${server.port()}"

def spec = suite("OFREP integration (WireMock)")(
test("getBooleanEvaluation returns the server's value") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/bool-flag"))
.willReturn(
okJson("""{"key":"bool-flag","value":true,"variant":"on","reason":"TARGETING_MATCH"}""")
)
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getBooleanEvaluation("bool-flag", java.lang.Boolean.FALSE, emptyContext)
assertTrue(result.getValue == java.lang.Boolean.TRUE) &&
assertTrue(result.getVariant == "on")
} finally provider.shutdown()
}
},
test("getStringEvaluation returns the server's value") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/greeting"))
.willReturn(okJson("""{"key":"greeting","value":"hello","reason":"STATIC"}"""))
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getStringEvaluation("greeting", "default", emptyContext)
assertTrue(result.getValue == "hello")
} finally provider.shutdown()
}
},
test("getIntegerEvaluation returns the server's value") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/max-items"))
.willReturn(okJson("""{"key":"max-items","value":42,"reason":"STATIC"}"""))
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getIntegerEvaluation("max-items", Integer.valueOf(0), emptyContext)
assertTrue(result.getValue == Integer.valueOf(42))
} finally provider.shutdown()
}
},
test("getDoubleEvaluation returns the server's value") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/rate"))
.willReturn(okJson("""{"key":"rate","value":2.5,"reason":"STATIC"}"""))
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getDoubleEvaluation("rate", java.lang.Double.valueOf(0.0), emptyContext)
assertTrue(result.getValue == java.lang.Double.valueOf(2.5))
} finally provider.shutdown()
}
},
test("getObjectEvaluation returns the server's object value") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/config"))
.willReturn(
okJson("""{"key":"config","value":{"timeout":30,"retries":3},"reason":"STATIC"}""")
)
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getObjectEvaluation("config", new Value(), emptyContext)
assertTrue(result.getValue != null) &&
assertTrue(result.getReason == "STATIC")
} finally provider.shutdown()
}
},
test("404 returns FLAG_NOT_FOUND error code and default value") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/missing-flag"))
.willReturn(
aResponse()
.withStatus(404)
.withHeader("Content-Type", "application/json")
.withBody("""{"errorCode":"FLAG_NOT_FOUND","errorDetails":"Flag not found"}""")
)
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getBooleanEvaluation("missing-flag", java.lang.Boolean.FALSE, emptyContext)
assertTrue(result.getValue == java.lang.Boolean.FALSE) &&
assertTrue(result.getErrorCode == ErrorCode.FLAG_NOT_FOUND)
} finally provider.shutdown()
}
},
test("401 surfaces a non-null error code and returns default") {
withMockServer { server =>
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/protected-flag"))
.willReturn(
aResponse()
.withStatus(401)
.withHeader("Content-Type", "application/json")
.withBody("""{"errorCode":"GENERAL","errorDetails":"unauthorized"}""")
)
)
val provider = OFREPProvider(baseUrl(server))
try {
val result = provider.getBooleanEvaluation("protected-flag", java.lang.Boolean.FALSE, emptyContext)
assertTrue(result.getValue == java.lang.Boolean.FALSE) &&
assertTrue(result.getErrorCode != null)
} finally provider.shutdown()
}
},
test("evaluation forwards targeting key and attributes to the server") {
withMockServer { server =>
// Match any body — we just assert the call was made with a JSON body that contains the expected fields.
server.stubFor(
post(urlEqualTo("/ofrep/v1/evaluate/flags/personalised"))
.withRequestBody(matchingJsonPath("$.context.targetingKey", equalTo("user-42")))
.withRequestBody(matchingJsonPath("$.context.plan", equalTo("premium")))
.willReturn(okJson("""{"key":"personalised","value":true,"reason":"TARGETING_MATCH"}"""))
)
val provider = OFREPProvider(baseUrl(server))
try {
val ctx = new MutableContext("user-42")
ctx.add("plan", "premium")
val result = provider.getBooleanEvaluation("personalised", java.lang.Boolean.FALSE, ctx)
assertTrue(result.getValue == java.lang.Boolean.TRUE)
} finally provider.shutdown()
}
}
) @@ TestAspect.sequential
}
Loading
Loading