Skip to content

Commit fcb11c6

Browse files
Add optimizely module with validated Scala factory (#140)
1 parent e305e08 commit fcb11c6

6 files changed

Lines changed: 687 additions & 1 deletion

File tree

build.sbt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ lazy val commonSettings = Seq(
9090
) ++ crossVersionSourceDirs
9191

9292
lazy val root = (project in file("."))
93-
.aggregate(core, testkit, extras, ofrep)
93+
.aggregate(core, testkit, extras, ofrep, optimizely)
9494
.settings(
9595
name := "zio-openfeature",
9696
publish / skip := true,
@@ -143,6 +143,22 @@ lazy val ofrep = (project in file("ofrep"))
143143
dependencyOverrides += "com.fasterxml.jackson.core" % "jackson-core" % "2.21.2"
144144
)
145145

146+
// Optimizely module - direct OpenFeature provider on top of the Optimizely Java SDK.
147+
// The unofficial `dev.openfeature.contrib.providers:optimizely` artifact is not published to Maven Central
148+
// at the time of this writing, so this module integrates with Optimizely directly via `com.optimizely.ab:core-api`
149+
// (decision engine) and `core-httpclient-impl` (datafile poller for the Optimizely CDN / self-hosted Agent).
150+
lazy val optimizely = (project in file("optimizely"))
151+
.dependsOn(core)
152+
.settings(
153+
name := "zio-openfeature-optimizely",
154+
commonSettings,
155+
libraryDependencies ++= Seq(
156+
"dev.zio" %% "zio" % zioVersion,
157+
"com.optimizely.ab" % "core-api" % "4.2.2",
158+
"com.optimizely.ab" % "core-httpclient-impl" % "4.2.2"
159+
)
160+
)
161+
146162
// Testkit module - testing utilities
147163
lazy val testkit = (project in file("testkit"))
148164
.dependsOn(core)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package zio.openfeature.optimizely
2+
3+
import dev.openfeature.sdk.{EvaluationContext => OFEvaluationContext, Value}
4+
import scala.jdk.CollectionConverters._
5+
6+
/** Converts an OpenFeature `EvaluationContext` into the `(userId, attributes)` pair that Optimizely's
7+
* `Optimizely.createUserContext(userId, attributes)` expects.
8+
*
9+
* Optimizely strictly requires a non-empty user identifier. If the OpenFeature context lacks a `targetingKey`, we
10+
* substitute an empty string so the resulting evaluation falls through to the flag's "default" rule rather than
11+
* throwing. The caller (the FeatureProvider implementation) translates that scenario into a typed evaluation result
12+
* downstream.
13+
*/
14+
private[optimizely] object ContextTransformer {
15+
16+
/** Result of transforming an OpenFeature context. `userId` is the Optimizely user identifier (from the OF
17+
* `targetingKey`). `attributes` is the typed attribute map ready to hand to Optimizely.
18+
*/
19+
final case class Transformed(userId: String, attributes: java.util.Map[String, Object])
20+
21+
def transform(ctx: OFEvaluationContext): Transformed = {
22+
val userId = Option(ctx).flatMap(c => Option(c.getTargetingKey)).getOrElse("")
23+
val attrs = Option(ctx).map(_.asObjectMap()).getOrElse(java.util.Collections.emptyMap[String, AnyRef]())
24+
Transformed(userId, normalize(attrs))
25+
}
26+
27+
/** Normalize OpenFeature `Value`-wrapped attributes into the Java primitives Optimizely's targeting engine
28+
* understands. Optimizely's `OptimizelyUserContext` accepts `String`, `Boolean`, `Number`, plus collections, but
29+
* does NOT accept the `Value` wrapper.
30+
*/
31+
private def normalize(attrs: java.util.Map[String, AnyRef]): java.util.Map[String, Object] = {
32+
val out = new java.util.HashMap[String, Object](attrs.size)
33+
attrs.asScala.foreach { case (k, v) =>
34+
val unwrapped: Object = v match {
35+
case null => null
36+
case x: Value => unwrapValue(x)
37+
case other => other
38+
}
39+
out.put(k, unwrapped)
40+
}
41+
out
42+
}
43+
44+
private def unwrapValue(v: Value): Object =
45+
if (v == null) null
46+
else if (v.isBoolean) java.lang.Boolean.valueOf(v.asBoolean())
47+
else if (v.isString) v.asString()
48+
else if (v.isNumber) java.lang.Double.valueOf(v.asDouble())
49+
else if (v.isInstant) v.asInstant()
50+
else if (v.isList) v.asList().asScala.map(unwrapValue).asJava
51+
else if (v.isStructure) {
52+
val javaMap = new java.util.HashMap[String, Object]()
53+
v.asStructure().asMap().asScala.foreach { case (k, vv) => javaMap.put(k, unwrapValue(vv)) }
54+
javaMap
55+
} else null
56+
}
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
package zio.openfeature.optimizely
2+
3+
import com.optimizely.ab.Optimizely
4+
import com.optimizely.ab.optimizelydecision.OptimizelyDecision
5+
import dev.openfeature.sdk.{
6+
EvaluationContext => OFEvaluationContext,
7+
EventProvider,
8+
ImmutableMetadata,
9+
Metadata,
10+
ProviderEvaluation,
11+
ProviderEventDetails,
12+
ProviderState,
13+
Reason,
14+
Structure,
15+
Value
16+
}
17+
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference}
18+
import java.util.concurrent.{CountDownLatch, TimeUnit}
19+
import scala.jdk.CollectionConverters._
20+
import scala.util.Try
21+
22+
/** OpenFeature `FeatureProvider` implementation that delegates to the Optimizely Java SDK.
23+
*
24+
* Construction is intentionally simple: take an already-configured `Optimizely` client and adapt its decision API to
25+
* OpenFeature's evaluation API. The Optimizely client is responsible for its own datafile polling, error handling, and
26+
* event dispatch; this class translates between the two worlds.
27+
*
28+
* '''Lifecycle notes:'''
29+
* - On `initialize()` we register an Optimizely `UpdateConfigNotification` handler. The first time it fires
30+
* (datafile loaded), we count down an internal latch so the OpenFeature `setProviderAndWait` returns cleanly.
31+
* Subsequent fires emit OpenFeature `PROVIDER_CONFIGURATION_CHANGED` events.
32+
* - If the datafile never arrives within `initWait`, `initialize()` throws — propagated by the OpenFeature SDK as a
33+
* failed init.
34+
* - `shutdown()` removes the notification handler and closes the underlying client.
35+
*
36+
* '''Decision mapping:'''
37+
* - Boolean: returns `decision.getEnabled`.
38+
* - String: returns the variable `variableKey` (default `"value"`) from `decision.getVariables`, falling back to
39+
* `decision.getVariationKey`, then to the supplied default.
40+
* - Integer / Double / Object: extracts the variable `variableKey` from `decision.getVariables` (typed).
41+
*
42+
* The convention of looking up a variable named `"value"` (overridable via an `"openfeature.variableKey"` attribute on
43+
* the evaluation context) mirrors what the OpenFeature contrib Optimizely provider does, so apps switching between
44+
* providers see consistent behaviour.
45+
*/
46+
final class OptimizelyFeatureProvider private[optimizely] (
47+
optimizely: Optimizely,
48+
initWait: java.time.Duration,
49+
closeOnShutdown: Boolean
50+
) extends EventProvider {
51+
52+
// Public construction is via the OptimizelyProvider factory in this package — the constructor is private to keep
53+
// lifecycle invariants (single initialize, registered handler) intact.
54+
55+
private val stateRef = new AtomicReference[ProviderState](ProviderState.NOT_READY)
56+
private val initialized = new AtomicBoolean(false)
57+
private val notificationHandle = new AtomicInteger(-1)
58+
private val initLatch = new CountDownLatch(1)
59+
60+
@scala.annotation.nowarn("msg=deprecated")
61+
override def getMetadata: Metadata = new Metadata {
62+
override def getName: String = OptimizelyFeatureProvider.Name
63+
}
64+
65+
@scala.annotation.nowarn("msg=deprecated")
66+
override def getState: ProviderState = stateRef.get()
67+
68+
override def initialize(ctx: OFEvaluationContext): Unit = {
69+
if (!initialized.compareAndSet(false, true)) return
70+
71+
val handlerId = optimizely.addUpdateConfigNotificationHandler { _ =>
72+
// The latch is single-use; subsequent updates are CONFIGURATION_CHANGED only.
73+
initLatch.countDown()
74+
emitProviderConfigurationChanged(ProviderEventDetails.builder().build())
75+
}
76+
// Optimizely's NotificationManager returns a non-positive id when registration fails (e.g. a duplicate handler
77+
// is already present). Without the handler we'd silently never count down the latch via the datafile-update
78+
// path and would always wait the full `initWait` window. Fail fast instead.
79+
if (handlerId <= 0) {
80+
stateRef.set(ProviderState.ERROR)
81+
throw new RuntimeException(
82+
s"Optimizely datafile update handler registration failed (returned id=$handlerId); cannot drive init"
83+
)
84+
}
85+
notificationHandle.set(handlerId)
86+
87+
if (optimizely.isValid) initLatch.countDown()
88+
89+
if (!initLatch.await(initWait.toMillis, TimeUnit.MILLISECONDS)) {
90+
stateRef.set(ProviderState.ERROR)
91+
throw new RuntimeException(
92+
s"Optimizely datafile did not load within $initWait; check the SDK key and network reachability"
93+
)
94+
}
95+
96+
if (optimizely.isValid) stateRef.set(ProviderState.READY)
97+
else {
98+
stateRef.set(ProviderState.ERROR)
99+
throw new RuntimeException(
100+
"Optimizely client reported invalid configuration after datafile load (possible auth or parse failure)"
101+
)
102+
}
103+
}
104+
105+
override def shutdown(): Unit = {
106+
val handle = notificationHandle.getAndSet(-1)
107+
if (handle > 0) {
108+
// Removing the handler is best-effort; if the notification center is already shut down we ignore.
109+
Try(optimizely.getNotificationCenter.removeNotificationListener(handle))
110+
()
111+
}
112+
if (closeOnShutdown) Try(optimizely.close())
113+
stateRef.set(ProviderState.NOT_READY)
114+
}
115+
116+
override def getBooleanEvaluation(
117+
key: String,
118+
defaultValue: java.lang.Boolean,
119+
ctx: OFEvaluationContext
120+
): ProviderEvaluation[java.lang.Boolean] =
121+
decide(key, ctx) match {
122+
case Right(d) =>
123+
ProviderEvaluation
124+
.builder[java.lang.Boolean]()
125+
.value(java.lang.Boolean.valueOf(d.getEnabled))
126+
.variant(d.getVariationKey)
127+
.reason(deriveReason(d))
128+
.flagMetadata(metadataFrom(d))
129+
.build()
130+
case Left(err) => failingEvaluation(defaultValue, err)
131+
}
132+
133+
override def getStringEvaluation(
134+
key: String,
135+
defaultValue: String,
136+
ctx: OFEvaluationContext
137+
): ProviderEvaluation[String] =
138+
decide(key, ctx) match {
139+
case Right(d) =>
140+
val variableKey = OptimizelyFeatureProvider.variableKey(ctx)
141+
val variable = readVariable[String](d, variableKey, classOf[String])
142+
// Fallback order: typed variable → variation key (still a meaningful Optimizely-driven answer) → OF default.
143+
val (value, usedDefault) = variable match {
144+
case Some(v) => (v, false)
145+
case None =>
146+
Option(d.getVariationKey) match {
147+
case Some(vk) => (vk, false)
148+
case None => (defaultValue, true)
149+
}
150+
}
151+
ProviderEvaluation
152+
.builder[String]()
153+
.value(value)
154+
.variant(d.getVariationKey)
155+
.reason(if (usedDefault) Reason.DEFAULT.name() else deriveReason(d))
156+
.flagMetadata(metadataFrom(d))
157+
.build()
158+
case Left(err) => failingEvaluation(defaultValue, err)
159+
}
160+
161+
override def getIntegerEvaluation(
162+
key: String,
163+
defaultValue: java.lang.Integer,
164+
ctx: OFEvaluationContext
165+
): ProviderEvaluation[java.lang.Integer] =
166+
typedEvaluation[java.lang.Integer](key, defaultValue, ctx, classOf[java.lang.Integer])
167+
168+
override def getDoubleEvaluation(
169+
key: String,
170+
defaultValue: java.lang.Double,
171+
ctx: OFEvaluationContext
172+
): ProviderEvaluation[java.lang.Double] =
173+
typedEvaluation[java.lang.Double](key, defaultValue, ctx, classOf[java.lang.Double])
174+
175+
/** Shared scaffold for Integer/Double evaluations: extract the named variable, fall back to the OF default with a
176+
* `DEFAULT` reason if the variable is missing. The Optimizely SDK throws `JsonParseException` from `getValue` when
177+
* the key is absent, so we wrap in `Try` and treat any failure as a missing variable.
178+
*/
179+
private def typedEvaluation[A](
180+
key: String,
181+
defaultValue: A,
182+
ctx: OFEvaluationContext,
183+
clazz: Class[A]
184+
): ProviderEvaluation[A] =
185+
decide(key, ctx) match {
186+
case Right(d) =>
187+
val variableKey = OptimizelyFeatureProvider.variableKey(ctx)
188+
val variable = readVariable[A](d, variableKey, clazz)
189+
val (value, usedDefault) = variable match {
190+
case Some(v) => (v, false)
191+
case None => (defaultValue, true)
192+
}
193+
ProviderEvaluation
194+
.builder[A]()
195+
.value(value)
196+
.variant(d.getVariationKey)
197+
.reason(if (usedDefault) Reason.DEFAULT.name() else deriveReason(d))
198+
.flagMetadata(metadataFrom(d))
199+
.build()
200+
case Left(err) => failingEvaluation(defaultValue, err)
201+
}
202+
203+
/** Reads the named variable as type `A`, swallowing the JSON-parse failures Optimizely throws when the key is absent.
204+
* `clazz` tells the Optimizely SDK which Java type to extract.
205+
*/
206+
private def readVariable[A](d: OptimizelyDecision, name: String, clazz: Class[A]): Option[A] =
207+
Try(d.getVariables.getValue(name, clazz)).toOption.flatMap(Option(_))
208+
209+
override def getObjectEvaluation(
210+
key: String,
211+
defaultValue: Value,
212+
ctx: OFEvaluationContext
213+
): ProviderEvaluation[Value] =
214+
decide(key, ctx) match {
215+
case Right(d) =>
216+
val map = Option(d.getVariables).map(_.toMap).getOrElse(java.util.Collections.emptyMap[String, Object]())
217+
val value = new Value(Structure.mapToStructure(map))
218+
ProviderEvaluation
219+
.builder[Value]()
220+
.value(value)
221+
.variant(d.getVariationKey)
222+
.reason(deriveReason(d))
223+
.flagMetadata(metadataFrom(d))
224+
.build()
225+
case Left(err) => failingEvaluation(defaultValue, err)
226+
}
227+
228+
// Common decision pipeline. Returns Left with an error reason string when no decision is possible.
229+
private def decide(key: String, ctx: OFEvaluationContext): Either[String, OptimizelyDecision] = {
230+
val transformed = ContextTransformer.transform(ctx)
231+
if (transformed.userId.isEmpty) Left("TARGETING_KEY_MISSING")
232+
else if (!optimizely.isValid) Left("PROVIDER_NOT_READY")
233+
else
234+
Try(optimizely.createUserContext(transformed.userId, transformed.attributes).decide(key)).toEither.left
235+
.map(t => Option(t.getMessage).getOrElse(t.getClass.getSimpleName))
236+
.flatMap { decision =>
237+
if (isFlagNotFound(decision)) Left("FLAG_NOT_FOUND")
238+
else Right(decision)
239+
}
240+
}
241+
242+
/** Identify a "flag not found" outcome from Optimizely's decision reasons. The Java SDK emits messages like `No flag
243+
* was found for key "..."` (via `DecisionMessage.FLAG_KEY_INVALID`); we match on a stable substring and the
244+
* `FLAG_KEY_INVALID` symbol so a future formatting tweak doesn't silently re-break this path.
245+
*/
246+
private def isFlagNotFound(d: OptimizelyDecision): Boolean =
247+
if (Option(d.getVariationKey).isDefined) false
248+
else {
249+
val errs = Option(d.getReasons).map(_.asScala.toList).getOrElse(Nil)
250+
errs.exists { reason =>
251+
val lower = reason.toLowerCase
252+
lower.contains("no flag was found") || lower.contains("flag_key_invalid")
253+
}
254+
}
255+
256+
private def deriveReason(d: OptimizelyDecision): String = {
257+
val errs = Option(d.getReasons).map(_.asScala.toList).getOrElse(Nil)
258+
if (errs.isEmpty && Option(d.getVariationKey).isDefined) Reason.TARGETING_MATCH.name()
259+
else if (Option(d.getVariationKey).isEmpty) Reason.DEFAULT.name()
260+
else Reason.TARGETING_MATCH.name()
261+
}
262+
263+
private def metadataFrom(d: OptimizelyDecision): ImmutableMetadata = {
264+
val builder = ImmutableMetadata.builder()
265+
Option(d.getRuleKey).foreach(builder.addString("optimizely.ruleKey", _))
266+
Option(d.getFlagKey).foreach(builder.addString("optimizely.flagKey", _))
267+
builder.build()
268+
}
269+
270+
private def failingEvaluation[A](defaultValue: A, errorCode: String): ProviderEvaluation[A] = {
271+
val mapped = errorCode match {
272+
case "TARGETING_KEY_MISSING" => dev.openfeature.sdk.ErrorCode.TARGETING_KEY_MISSING
273+
case "PROVIDER_NOT_READY" => dev.openfeature.sdk.ErrorCode.PROVIDER_NOT_READY
274+
case "FLAG_NOT_FOUND" => dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND
275+
case _ => dev.openfeature.sdk.ErrorCode.GENERAL
276+
}
277+
ProviderEvaluation
278+
.builder[A]()
279+
.value(defaultValue)
280+
.reason(Reason.ERROR.name())
281+
.errorCode(mapped)
282+
.errorMessage(errorCode)
283+
.build()
284+
}
285+
}
286+
287+
object OptimizelyFeatureProvider {
288+
val Name: String = "Optimizely"
289+
290+
/** Context attribute key callers can set to override which Optimizely variable is read for typed evaluations. */
291+
val VariableKeyAttribute: String = "openfeature.variableKey"
292+
293+
/** Default Optimizely variable name used by typed evaluations when the context doesn't override. */
294+
val DefaultVariableKey: String = "value"
295+
296+
private[optimizely] def variableKey(ctx: OFEvaluationContext): String = {
297+
val v = Option(ctx).flatMap(c => Option(c.getValue(VariableKeyAttribute)))
298+
v.flatMap(value => Option(value.asString())).filter(_.nonEmpty).getOrElse(DefaultVariableKey)
299+
}
300+
}

0 commit comments

Comments
 (0)