diff --git a/README.md b/README.md index ebfa92ca..2bb3b782 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@

- - Specification + + Specification diff --git a/src/main/java/dev/openfeature/sdk/EventDetails.java b/src/main/java/dev/openfeature/sdk/EventDetails.java index 3f6db159..d4ecac93 100644 --- a/src/main/java/dev/openfeature/sdk/EventDetails.java +++ b/src/main/java/dev/openfeature/sdk/EventDetails.java @@ -7,19 +7,23 @@ /** * The details of a particular event. */ -@Data @SuperBuilder(toBuilder = true) +@Data +@SuperBuilder(toBuilder = true) public class EventDetails extends ProviderEventDetails { private String clientName; + private String providerName; - static EventDetails fromProviderEventDetails(ProviderEventDetails providerEventDetails) { - return EventDetails.fromProviderEventDetails(providerEventDetails, null); + static EventDetails fromProviderEventDetails(ProviderEventDetails providerEventDetails, String providerName) { + return EventDetails.fromProviderEventDetails(providerEventDetails, providerName, null); } static EventDetails fromProviderEventDetails( ProviderEventDetails providerEventDetails, + @Nullable String providerName, @Nullable String clientName) { return EventDetails.builder() .clientName(clientName) + .providerName(providerName) .flagsChanged(providerEventDetails.getFlagsChanged()) .eventMetadata(providerEventDetails.getEventMetadata()) .message(providerEventDetails.getMessage()) diff --git a/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java b/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java index b324c07c..af48d877 100644 --- a/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java +++ b/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java @@ -1,5 +1,7 @@ package dev.openfeature.sdk; +import java.util.Optional; + import javax.annotation.Nullable; import lombok.AllArgsConstructor; @@ -8,7 +10,8 @@ import lombok.NoArgsConstructor; /** - * Contains information about how the provider resolved a flag, including the resolved value. + * Contains information about how the provider resolved a flag, including the + * resolved value. * * @param the type of the flag being evaluated. */ @@ -20,11 +23,15 @@ public class FlagEvaluationDetails implements BaseEvaluation { private String flagKey; private T value; - @Nullable private String variant; - @Nullable private String reason; + @Nullable + private String variant; + @Nullable + private String reason; private ErrorCode errorCode; - @Nullable private String errorMessage; - @Builder.Default private ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); + @Nullable + private String errorMessage; + @Builder.Default + private ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); /** * Generate detail payload from the provider response. @@ -42,7 +49,8 @@ public static FlagEvaluationDetails from(ProviderEvaluation providerEv .reason(providerEval.getReason()) .errorMessage(providerEval.getErrorMessage()) .errorCode(providerEval.getErrorCode()) - .flagMetadata(providerEval.getFlagMetadata()) + .flagMetadata( + Optional.ofNullable(providerEval.getFlagMetadata()).orElse(ImmutableMetadata.builder().build())) .build(); } } diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index 47c09388..7448ad78 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -100,12 +101,12 @@ public EvaluationContext getEvaluationContext() { public void setProvider(FeatureProvider provider) { try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { providerRepository.setProvider( - provider, + provider, this::attachEventProvider, this::emitReady, this::detachEventProvider, this::emitError, - false); + false); } } @@ -118,12 +119,12 @@ public void setProvider(FeatureProvider provider) { public void setProvider(String clientName, FeatureProvider provider) { try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { providerRepository.setProvider(clientName, - provider, - this::attachEventProvider, - this::emitReady, - this::detachEventProvider, - this::emitError, - false); + provider, + this::attachEventProvider, + this::emitReady, + this::detachEventProvider, + this::emitError, + false); } } @@ -133,12 +134,12 @@ public void setProvider(String clientName, FeatureProvider provider) { public void setProviderAndWait(FeatureProvider provider) { try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { providerRepository.setProvider( - provider, - this::attachEventProvider, - this::emitReady, - this::detachEventProvider, - this::emitError, - true); + provider, + this::attachEventProvider, + this::emitReady, + this::detachEventProvider, + this::emitError, + true); } } @@ -151,18 +152,18 @@ public void setProviderAndWait(FeatureProvider provider) { public void setProviderAndWait(String clientName, FeatureProvider provider) { try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { providerRepository.setProvider(clientName, - provider, - this::attachEventProvider, - this::emitReady, - this::detachEventProvider, - this::emitError, - true); + provider, + this::attachEventProvider, + this::emitReady, + this::detachEventProvider, + this::emitError, + true); } } private void attachEventProvider(FeatureProvider provider) { if (provider instanceof EventProvider) { - ((EventProvider)provider).attach((p, event, details) -> { + ((EventProvider) provider).attach((p, event, details) -> { runHandlersForProvider(p, event, details); }); } @@ -174,7 +175,7 @@ private void emitReady(FeatureProvider provider) { private void detachEventProvider(FeatureProvider provider) { if (provider instanceof EventProvider) { - ((EventProvider)provider).detach(); + ((EventProvider) provider).detach(); } } @@ -229,9 +230,10 @@ public void clearHooks() { /** * Shut down and reset the current status of OpenFeature API. - * This call cleans up all active providers and attempts to shut down internal event handling mechanisms. + * This call cleans up all active providers and attempts to shut down internal + * event handling mechanisms. * Once shut down is complete, API is reset and ready to use again. - * */ + */ public void shutdown() { try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { providerRepository.shutdown(); @@ -302,9 +304,9 @@ void removeHandler(String clientName, ProviderEvent event, Consumer handler) { try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { - // if the provider is READY, run immediately - if (ProviderEvent.PROVIDER_READY.equals(event) - && ProviderState.READY.equals(this.providerRepository.getProvider(clientName).getState())) { + // if the provider is in the state associated with event, run immediately + if (Optional.ofNullable(this.providerRepository.getProvider(clientName).getState()) + .orElse(ProviderState.READY).matchesEvent(event)) { eventSupport.runHandler(handler, EventDetails.builder().clientName(clientName).build()); } eventSupport.addClientHandler(clientName, event, handler); @@ -315,30 +317,36 @@ void addHandler(String clientName, ProviderEvent event, Consumer h * Runs the handlers associated with a particular provider. * * @param provider the provider from where this event originated - * @param event the event type - * @param details the event details + * @param event the event type + * @param details the event details */ private void runHandlersForProvider(FeatureProvider provider, ProviderEvent event, ProviderEventDetails details) { try (AutoCloseableLock __ = lock.readLockAutoCloseable()) { - + List clientNamesForProvider = providerRepository - .getClientNamesForProvider(provider); - + .getClientNamesForProvider(provider); + + final String providerName = Optional.ofNullable(provider.getMetadata()) + .map(metadata -> metadata.getName()) + .orElse(null); + // run the global handlers - eventSupport.runGlobalHandlers(event, EventDetails.fromProviderEventDetails(details)); + eventSupport.runGlobalHandlers(event, EventDetails.fromProviderEventDetails(details, providerName)); // run the handlers associated with named clients for this provider - clientNamesForProvider.forEach(name -> { - eventSupport.runClientHandlers(name, event, EventDetails.fromProviderEventDetails(details, name)); + clientNamesForProvider.forEach(name -> { + eventSupport.runClientHandlers(name, event, + EventDetails.fromProviderEventDetails(details, providerName, name)); }); - + if (providerRepository.isDefaultProvider(provider)) { // run handlers for clients that have no bound providers (since this is the default) Set allClientNames = eventSupport.getAllClientNames(); Set boundClientNames = providerRepository.getAllBoundClientNames(); allClientNames.removeAll(boundClientNames); allClientNames.forEach(name -> { - eventSupport.runClientHandlers(name, event, EventDetails.fromProviderEventDetails(details, name)); + eventSupport.runClientHandlers(name, event, + EventDetails.fromProviderEventDetails(details, providerName, name)); }); } } diff --git a/src/main/java/dev/openfeature/sdk/ProviderState.java b/src/main/java/dev/openfeature/sdk/ProviderState.java index 6685f8fe..a66d4e94 100644 --- a/src/main/java/dev/openfeature/sdk/ProviderState.java +++ b/src/main/java/dev/openfeature/sdk/ProviderState.java @@ -4,5 +4,17 @@ * Indicates the state of the provider. */ public enum ProviderState { - READY, NOT_READY, ERROR; + READY, NOT_READY, ERROR, STALE; + + /** + * Returns true if the passed ProviderEvent maps to this ProviderState. + * + * @param event event to compare + * @return boolean if matches. + */ + boolean matchesEvent(ProviderEvent event) { + return this == READY && event == ProviderEvent.PROVIDER_READY + || this == STALE && event == ProviderEvent.PROVIDER_STALE + || this == ERROR && event == ProviderEvent.PROVIDER_ERROR; + } } diff --git a/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java b/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java index 8d1c4514..4fdc433b 100644 --- a/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java +++ b/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java @@ -4,10 +4,19 @@ class DoSomethingProvider implements FeatureProvider { static final String name = "Something"; // Flag evaluation metadata - static final ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); + static final ImmutableMetadata DEFAULT_METADATA = ImmutableMetadata.builder().build(); + private ImmutableMetadata flagMetadata; private EvaluationContext savedContext; + public DoSomethingProvider() { + this.flagMetadata = DEFAULT_METADATA; + } + + public DoSomethingProvider(ImmutableMetadata flagMetadata) { + this.flagMetadata = flagMetadata; + } + EvaluationContext getMergedContext() { return savedContext; } diff --git a/src/test/java/dev/openfeature/sdk/EventsTest.java b/src/test/java/dev/openfeature/sdk/EventsTest.java index 70f81657..f9f8e4b8 100644 --- a/src/test/java/dev/openfeature/sdk/EventsTest.java +++ b/src/test/java/dev/openfeature/sdk/EventsTest.java @@ -24,7 +24,7 @@ class EventsTest { - private static final int TIMEOUT = 200; + private static final int TIMEOUT = 300; private static final int INIT_DELAY = TIMEOUT / 2; @AfterAll @@ -470,7 +470,7 @@ void handlersRunIfOneThrows() throws Exception { @Test @DisplayName("should have all properties") @Specification(number = "5.2.4", text = "The handler function MUST accept a event details parameter.") - @Specification(number = "5.2.3", text = "The event details MUST contain the client name associated with the event.") + @Specification(number = "5.2.3", text = "The `event details` MUST contain the `provider name` associated with the event.") void shouldHaveAllProperties() throws Exception { final Consumer handler1 = mockHandler(); final Consumer handler2 = mockHandler(); @@ -514,9 +514,9 @@ void shouldHaveAllProperties() throws Exception { @Test @DisplayName("if the provider is ready handlers must run immediately") - @Specification(number = "5.3.3", text = "PROVIDER_READY handlers attached after the provider is already in a ready state MUST run immediately.") - void readyMustRunImmediately() throws Exception { - final String name = "readyMustRunImmediately"; + @Specification(number = "5.3.3", text = "Handlers attached after the provider is already in the associated state, MUST run immediately.") + void matchingReadyEventsMustRunImmediately() throws Exception { + final String name = "matchingEventsMustRunImmediately"; final Consumer handler = mockHandler(); // provider which is already ready @@ -529,6 +529,40 @@ void readyMustRunImmediately() throws Exception { verify(handler, timeout(TIMEOUT)).accept(any()); } + @Test + @DisplayName("if the provider is ready handlers must run immediately") + @Specification(number = "5.3.3", text = "Handlers attached after the provider is already in the associated state, MUST run immediately.") + void matchingStaleEventsMustRunImmediately() throws Exception { + final String name = "matchingEventsMustRunImmediately"; + final Consumer handler = mockHandler(); + + // provider which is already stale + TestEventsProvider provider = new TestEventsProvider(ProviderState.STALE); + OpenFeatureAPI.getInstance().setProvider(name, provider); + + // should run even thought handler was added after stale + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderStale(handler); + verify(handler, timeout(TIMEOUT)).accept(any()); + } + + @Test + @DisplayName("if the provider is ready handlers must run immediately") + @Specification(number = "5.3.3", text = "Handlers attached after the provider is already in the associated state, MUST run immediately.") + void matchingErrorEventsMustRunImmediately() throws Exception { + final String name = "matchingEventsMustRunImmediately"; + final Consumer handler = mockHandler(); + + // provider which is already in error + TestEventsProvider provider = new TestEventsProvider(ProviderState.ERROR); + OpenFeatureAPI.getInstance().setProvider(name, provider); + + // should run even thought handler was added after error + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderError(handler); + verify(handler, timeout(TIMEOUT)).accept(any()); + } + @Test @DisplayName("must persist across changes") @Specification(number = "5.2.6", text = "Event handlers MUST persist across provider changes.") @@ -560,6 +594,7 @@ void mustPersistAcrossChanges() throws Exception { @Nested class HandlerRemoval { + @Specification(number="5.2.7", text="The API and client MUST provide a function allowing the removal of event handlers.") @Test @DisplayName("should not run removed events") void removedEventsShouldNotRun() { diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java index 35eb0769..52c36dc5 100644 --- a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java @@ -1,10 +1,11 @@ package dev.openfeature.sdk; -import static dev.openfeature.sdk.DoSomethingProvider.flagMetadata; +import static dev.openfeature.sdk.DoSomethingProvider.DEFAULT_METADATA; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -140,8 +141,8 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { assertTrue(hooks.contains(m2)); } - @Specification(number="1.3.1", text="The client MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns the flag value.") - @Specification(number="1.3.2.1", text="The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.") + @Specification(number="1.3.1.1", text="The client MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns the flag value.") + @Specification(number="1.3.3.1", text="The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.") @Test void value_flags() { FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); @@ -169,12 +170,12 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { assertEquals(null, c.getObjectValue(key, new Value(), new ImmutableContext(), FlagEvaluationOptions.builder().build())); } - @Specification(number="1.4.1", text="The client MUST provide methods for detailed flag value evaluation with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns an evaluation details structure.") - @Specification(number="1.4.2", text="The evaluation details structure's value field MUST contain the evaluated flag value.") - @Specification(number="1.4.3.1", text="The evaluation details structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped value field.") - @Specification(number="1.4.4", text="The evaluation details structure's flag key field MUST contain the flag key argument passed to the detailed flag evaluation method.") - @Specification(number="1.4.5", text="In cases of normal execution, the evaluation details structure's variant field MUST contain the value of the variant field in the flag resolution structure returned by the configured provider, if the field is set.") - @Specification(number="1.4.6", text="In cases of normal execution, the evaluation details structure's reason field MUST contain the value of the reason field in the flag resolution structure returned by the configured provider, if the field is set.") + @Specification(number="1.4.1.1", text="The client MUST provide methods for detailed flag value evaluation with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns an evaluation details structure.") + @Specification(number="1.4.3", text="The evaluation details structure's value field MUST contain the evaluated flag value.") + @Specification(number="1.4.4.1", text="The evaluation details structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped value field.") + @Specification(number="1.4.5", text="The evaluation details structure's flag key field MUST contain the flag key argument passed to the detailed flag evaluation method.") + @Specification(number="1.4.6", text="In cases of normal execution, the evaluation details structure's variant field MUST contain the value of the variant field in the flag resolution structure returned by the configured provider, if the field is set.") + @Specification(number="1.4.7", text="In cases of normal execution, the `evaluation details` structure's `reason` field MUST contain the value of the `reason` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.") @Test void detail_flags() { FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); Client c = api.getClient(); @@ -184,7 +185,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { .flagKey(key) .value(false) .variant(null) - .flagMetadata(flagMetadata) + .flagMetadata(DEFAULT_METADATA) .build(); assertEquals(bd, c.getBooleanDetails(key, true)); assertEquals(bd, c.getBooleanDetails(key, true, new ImmutableContext())); @@ -194,7 +195,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { .flagKey(key) .value("tset") .variant(null) - .flagMetadata(flagMetadata) + .flagMetadata(DEFAULT_METADATA) .build(); assertEquals(sd, c.getStringDetails(key, "test")); assertEquals(sd, c.getStringDetails(key, "test", new ImmutableContext())); @@ -203,7 +204,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { FlagEvaluationDetails id = FlagEvaluationDetails.builder() .flagKey(key) .value(400) - .flagMetadata(flagMetadata) + .flagMetadata(DEFAULT_METADATA) .build(); assertEquals(id, c.getIntegerDetails(key, 4)); assertEquals(id, c.getIntegerDetails(key, 4, new ImmutableContext())); @@ -212,7 +213,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { FlagEvaluationDetails dd = FlagEvaluationDetails.builder() .flagKey(key) .value(40.0) - .flagMetadata(flagMetadata) + .flagMetadata(DEFAULT_METADATA) .build(); assertEquals(dd, c.getDoubleDetails(key, .4)); assertEquals(dd, c.getDoubleDetails(key, .4, new ImmutableContext())); @@ -234,9 +235,10 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { verify(invocationHook, times(1)).before(any(), any()); } - @Specification(number="1.4.9", text="Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the default value in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.") - @Specification(number="1.4.7", text="In cases of abnormal execution, the `evaluation details` structure's `error code` field **MUST** contain an `error code`.") - @Specification(number="1.4.12", text="In cases of abnormal execution, the `evaluation details` structure's `error message` field **MAY** contain a string containing additional details about the nature of the error.") + @Specification(number="1.4.8", text="In cases of abnormal execution, the `evaluation details` structure's `error code` field **MUST** contain an `error code`.") + @Specification(number="1.4.9", text="In cases of abnormal execution (network failure, unhandled error, etc) the `reason` field in the `evaluation details` SHOULD indicate an error.") + @Specification(number="1.4.10", text="Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the `default value` in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.") + @Specification(number="1.4.13", text="In cases of abnormal execution, the `evaluation details` structure's `error message` field **MAY** contain a string containing additional details about the nature of the error.") @Test void broken_provider() { FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); @@ -246,7 +248,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { assertEquals(TestConstants.BROKEN_MESSAGE, details.getErrorMessage()); } - @Specification(number="1.4.10", text="In the case of abnormal execution, the client SHOULD log an informative error message.") + @Specification(number="1.4.11", text="In the case of abnormal execution, the client SHOULD log an informative error message.") @Test void log_on_error() throws NotImplementedException { FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); @@ -269,7 +271,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { assertEquals("test", c2.getMetadata().getName()); } - @Specification(number="1.4.8", text="In cases of abnormal execution (network failure, unhandled error, etc) the reason field in the evaluation details SHOULD indicate an error.") + @Specification(number="1.4.9", text="In cases of abnormal execution (network failure, unhandled error, etc) the reason field in the evaluation details SHOULD indicate an error.") @Test void reason_is_error_when_there_are_errors() { FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); @@ -277,8 +279,17 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { assertEquals(Reason.ERROR.toString(), result.getReason()); } - @Specification(number="3.2.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.") - @Specification(number="3.2.2", text="Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.") + @Specification(number="1.4.14", text="If the flag metadata field in the flag resolution structure returned by the configured provider is set, the evaluation details structure's flag metadata field MUST contain that value. Otherwise, it MUST contain an empty record.") + @Test void flag_metadata_passed() { + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider(null)); + Client c = api.getClient(); + FlagEvaluationDetails result = c.getBooleanDetails("test", false); + assertNotNull(result.getFlagMetadata()); + } + + @Specification(number="3.2.1.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.") + @Specification(number="3.2.2.1", text="The API MUST have a method for setting the global evaluation context.") + @Specification(number="3.2.3", text="Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.") @Test void multi_layer_context_merges_correctly() { DoSomethingProvider provider = new DoSomethingProvider(); FeatureProviderTestUtils.setFeatureProvider(provider); @@ -315,13 +326,22 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { } - @Specification(number="1.3.3", text="The client SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied default value should be returned.") + @Specification(number="1.3.4", text="The client SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied default value should be returned.") @Test void type_system_prevents_this() {} @Specification(number="1.1.7", text="The client creation function MUST NOT throw, or otherwise abnormally terminate.") @Test void constructor_does_not_throw() {} - @Specification(number="1.4.11", text="The client SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.") + @Specification(number="1.4.12", text="The client SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.") @Test void one_thread_per_request_model() {} + @Specification(number="1.4.14.1", text="Condition: Flag metadata MUST be immutable.") + @Test void compiler_enforced() {} + + @Specification(number="1.4.2.1", text="The client MUST provide methods for detailed flag value evaluation with parameters flag key (string, required), default value (boolean | number | string | structure, required), and evaluation options (optional), which returns an evaluation details structure.") + @Specification(number="1.3.2.1", text="The client MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters flag key (string, required), default value (boolean | number | string | structure, required), and evaluation options (optional), which returns the flag value.") + @Specification(number="3.2.2.2", text="The Client and invocation MUST NOT have a method for supplying evaluation context.") + @Specification(number="3.2.4.1", text="When the global evaluation context is set, the on context changed handler MUST run.") + @Test void not_applicable_for_dynamic_context() {} + } diff --git a/src/test/java/dev/openfeature/sdk/HookContextTest.java b/src/test/java/dev/openfeature/sdk/HookContextTest.java index 14a2ef2b..763069fd 100644 --- a/src/test/java/dev/openfeature/sdk/HookContextTest.java +++ b/src/test/java/dev/openfeature/sdk/HookContextTest.java @@ -23,4 +23,7 @@ class HookContextTest { assertTrue(Metadata.class.isAssignableFrom(hc.getProviderMetadata().getClass())); } + @Specification(number="4.3.3.1", text="The before stage MUST run before flag resolution occurs. It accepts a hook context (required) and hook hints (optional) as parameters. It has no return value.") + @Test void not_applicable_for_dynamic_context() {} + } \ No newline at end of file diff --git a/src/test/java/dev/openfeature/sdk/HookSpecTest.java b/src/test/java/dev/openfeature/sdk/HookSpecTest.java index d1daa705..def331db 100644 --- a/src/test/java/dev/openfeature/sdk/HookSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSpecTest.java @@ -154,7 +154,7 @@ void emptyApiHooks() { .build(); } - @Specification(number="4.3.2", text="The before stage MUST run before flag resolution occurs. It accepts a hook context (required) and hook hints (optional) as parameters and returns either an evaluation context or nothing.") + @Specification(number="4.3.2.1", text="The before stage MUST run before flag resolution occurs. It accepts a hook context (required) and hook hints (optional) as parameters and returns either an evaluation context or nothing.") @Test void before_runs_ahead_of_evaluation() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setProvider(new AlwaysBrokenProvider()); @@ -181,11 +181,11 @@ void emptyApiHooks() { verify(h, times(0)).error(any(), any(), any()); } + @Specification(number="4.3.6", text="The after stage MUST run after flag resolution occurs. It accepts a hook context (required), flag evaluation details (required) and hook hints (optional). It has no return value.") + @Specification(number="4.3.7", text="The error hook MUST run when errors are encountered in the before stage, the after stage or during flag resolution. It accepts hook context (required), exception representing what went wrong (required), and hook hints (optional). It has no return value.") + @Specification(number="4.3.8", text="The finally hook MUST run after the before, after, and error stages. It accepts a hook context (required) and hook hints (optional). There is no return value.") @Specification(number="4.4.1", text="The API, Client, Provider, and invocation MUST have a method for registering hooks.") - @Specification(number="4.3.5", text="The after stage MUST run after flag resolution occurs. It accepts a hook context (required), flag evaluation details (required) and hook hints (optional). It has no return value.") @Specification(number="4.4.2", text="Hooks MUST be evaluated in the following order: - before: API, Client, Invocation, Provider - after: Provider, Invocation, Client, API - error (if applicable): Provider, Invocation, Client, API - finally: Provider, Invocation, Client, API") - @Specification(number="4.3.6", text="The error hook MUST run when errors are encountered in the before stage, the after stage or during flag resolution. It accepts hook context (required), exception representing what went wrong (required), and hook hints (optional). It has no return value.") - @Specification(number="4.3.7", text="The finally hook MUST run after the before, after, and error stages. It accepts a hook context (required) and hook hints (optional). There is no return value.") @Test void hook_eval_order() { List evalOrder = new ArrayList<>(); OpenFeatureAPI api = OpenFeatureAPI.getInstance(); @@ -446,7 +446,7 @@ public void finallyAfter(HookContext ctx, Map hints) { } @Specification(number = "4.1.4", text = "The evaluation context MUST be mutable only within the before hook.") - @Specification(number = "4.3.3", text = "Any evaluation context returned from a before hook MUST be passed to subsequent before hooks (via HookContext).") + @Specification(number = "4.3.4", text = "Any `evaluation context` returned from a `before` hook MUST be passed to subsequent `before` hooks (via `HookContext`).") @Test void beforeContextUpdated() { EvaluationContext ctx = new ImmutableContext(); Hook hook = mockBooleanHook(); @@ -471,7 +471,7 @@ public void finallyAfter(HookContext ctx, Map hints) { } - @Specification(number="4.3.4", text="When before hooks have finished executing, any resulting evaluation context MUST be merged with the existing evaluation context.") + @Specification(number="4.3.5", text="When before hooks have finished executing, any resulting evaluation context MUST be merged with the existing evaluation context.") @Test void mergeHappensCorrectly() { Map attributes= new HashMap<>(); attributes.put("test", new Value("works")); @@ -561,7 +561,7 @@ private Client getClient(FeatureProvider provider) { @Specification(number="4.3.1", text="Hooks MUST specify at least one stage.") @Test void default_methods_so_impossible() {} - @Specification(number="4.3.8.1", text="Instead of finally, finallyAfter SHOULD be used.") + @Specification(number="4.3.9.1", text="Instead of finally, finallyAfter SHOULD be used.") @SneakyThrows @Test void doesnt_use_finally() { assertThatCode(() -> Hook.class.getMethod("finally", HookContext.class, Map.class)) diff --git a/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java index 0ab5e371..270ac77e 100644 --- a/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java @@ -30,7 +30,7 @@ void mustCallInitializeFunctionOfTheNewlyRegisteredProviderBeforeUsingItForFlagE verify(featureProvider, timeout(1000)).initialize(any()); } - @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + @Specification(number = "1.4.10", text = "Methods, functions, or operations on the client MUST NOT throw " + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + "the purposes for configuration or setup.") @@ -65,7 +65,7 @@ void mustCallInitializeFunctionOfTheNewlyRegisteredNamedProviderBeforeUsingItFor verify(featureProvider, timeout(1000)).initialize(any()); } - @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + @Specification(number = "1.4.10", text = "Methods, functions, or operations on the client MUST NOT throw " + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + "the purposes for configuration or setup.") diff --git a/src/test/java/dev/openfeature/sdk/LockingTest.java b/src/test/java/dev/openfeature/sdk/LockingTest.java index d9601e85..f58795ad 100644 --- a/src/test/java/dev/openfeature/sdk/LockingTest.java +++ b/src/test/java/dev/openfeature/sdk/LockingTest.java @@ -12,9 +12,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; +@Isolated() class LockingTest { private static OpenFeatureAPI api; @@ -26,11 +28,12 @@ class LockingTest { @BeforeAll static void beforeAll() { api = OpenFeatureAPI.getInstance(); + OpenFeatureAPI.getInstance().setProvider("LockingTest", new NoOpProvider()); } @BeforeEach void beforeEach() { - client = (OpenFeatureClient) api.getClient(); + client = (OpenFeatureClient) api.getClient("LockingTest"); apiLock = setupLock(apiLock, mockInnerReadLock(), mockInnerWriteLock()); OpenFeatureAPI.lock = apiLock; diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java index a49bf643..10b38e6c 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java @@ -27,6 +27,19 @@ void namedProviderTest() { .isEqualTo(api.getProviderMetadata("namedProviderTest").getName()); } + @Specification(number="1.1.3", text="The API MUST provide a function to bind a given provider to one or more client names. If the client-name already has a bound provider, it is overwritten with the new mapping.") + @Test + void namedProviderOverwrittenTest() { + String name = "namedProviderOverwrittenTest"; + FeatureProvider provider1 = new NoOpProvider(); + FeatureProvider provider2 = new DoSomethingProvider(); + FeatureProviderTestUtils.setFeatureProvider(name, provider1); + FeatureProviderTestUtils.setFeatureProvider(name, provider2); + + assertThat(OpenFeatureAPI.getInstance().getProvider(name).getMetadata().getName()) + .isEqualTo(DoSomethingProvider.name); + } + @Test void settingDefaultProviderToNullErrors() { assertThatCode(() -> api.setProvider(null)).isInstanceOf(IllegalArgumentException.class); diff --git a/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java b/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java index f5e5e6a4..a87cc517 100644 --- a/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -39,7 +40,7 @@ void flag_value_set() { } - @Specification(number = "2.2.5", text = "The `provider` SHOULD populate the `resolution details` structure's `reason` field with `\"STATIC\"`, `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"CACHED\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.") + @Specification(number = "2.2.5", text = "The `provider` SHOULD populate the `resolution details` structure's `reason` field with `\"STATIC\"`, `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"CACHED\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"STALE\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.") @Test void has_reason() { ProviderEvaluation result = p.getBooleanEvaluation("key", false, new ImmutableContext()); @@ -76,10 +77,50 @@ void variant_set() { assertNotNull(boolean_result.getReason()); } + @Specification(number = "2.2.10", text = "`flag metadata` MUST be a structure supporting the definition of arbitrary properties, with keys of type `string`, and values of type `boolean | string | number`.") + @Test + void flag_metadata_structure() { + ImmutableMetadata metadata = ImmutableMetadata.builder() + .addBoolean("bool", true) + .addDouble("double", 1.1d) + .addFloat("float", 2.2f) + .addInteger("int", 3) + .addLong("long", 1l) + .addString("string", "str") + .build(); + + assertEquals(true, metadata.getBoolean("bool")); + assertEquals(1.1d, metadata.getDouble("double")); + assertEquals(2.2f, metadata.getFloat("float")); + assertEquals(3, metadata.getInteger("int")); + assertEquals(1l, metadata.getLong("long")); + assertEquals("str", metadata.getString("string")); + } + @Specification(number = "2.3.1", text = "The provider interface MUST define a provider hook mechanism which can be optionally implemented in order to add hook instances to the evaluation life-cycle.") @Specification(number = "4.4.1", text = "The API, Client, Provider, and invocation MUST have a method for registering hooks.") @Test void provider_hooks() { assertEquals(0, p.getProviderHooks().size()); } + + @Specification(number = "2.4.2", text = "The provider MAY define a status field/accessor which indicates the readiness of the provider, with possible values NOT_READY, READY, or ERROR.") + @Test + void defines_status() { + assertTrue(p.getState() instanceof ProviderState); + } + + @Specification(number = "2.4.3", text = "The provider MUST set its status field/accessor to READY if its initialize function terminates normally.") + @Specification(number = "2.4.4", text = "The provider MUST set its status field to ERROR if its initialize function terminates abnormally.") + @Specification(number = "2.2.9", text = "The provider SHOULD populate the resolution details structure's flag metadata field.") + @Specification(number = "2.4.1", text = "The provider MAY define an initialize function which accepts the global evaluation context as an argument and performs initialization logic relevant to the provider.") + @Specification(number = "2.5.1", text = "The provider MAY define a mechanism to gracefully shutdown and dispose of resources.") + @Test + void provider_responsibility() { + } + + @Specification(number = "2.6.1", text = "The provider MAY define an on context changed handler, which takes an argument for the previous context and the newly set context, in order to respond to an evaluation context change.") + @Test + void not_applicable_for_dynamic_context() { + } } diff --git a/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java index 8659ce78..b0a0a9d1 100644 --- a/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java @@ -35,7 +35,7 @@ void mustInvokeShutdownMethodOnPreviouslyRegisteredProviderOnceItShouldNotBeUsed verify(featureProvider, timeout(1000)).shutdown(); } - @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + @Specification(number = "1.4.10", text = "Methods, functions, or operations on the client MUST NOT throw " + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + "the purposes for configuration or setup.") @@ -68,7 +68,7 @@ void mustInvokeShutdownMethodOnPreviouslyRegisteredProviderOnceItShouldNotBeUsed verify(featureProvider, timeout(1000)).shutdown(); } - @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + @Specification(number = "1.4.10", text = "Methods, functions, or operations on the client MUST NOT throw " + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + "the purposes for configuration or setup.") diff --git a/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java b/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java index 25650bf6..af239644 100644 --- a/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java +++ b/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java @@ -16,6 +16,13 @@ public class TestEventsProvider extends EventProvider { private ProviderState state = ProviderState.NOT_READY; private boolean shutDown = false; private int initTimeoutMs = 0; + private String name = "test"; + private Metadata metadata = new Metadata() { + @Override + public String getName() { + return name; + } + }; @Override public ProviderState getState() { @@ -64,7 +71,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception { @Override public Metadata getMetadata() { - throw new UnsupportedOperationException("Unimplemented method 'getMetadata'"); + return this.metadata; } @Override