Skip to content

Commit 54466db

Browse files
marcozabeltoddbaert
authored andcommitted
feat: restore OpenFeatureAPIFactory with package-private constructor
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
1 parent 7885372 commit 54466db

5 files changed

Lines changed: 201 additions & 29 deletions

File tree

src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
* <p>Most applications should use the global singleton via {@link #getInstance()}; configuration
2222
* there is shared across all {@link Client}s. For dependency-injection frameworks, testing, or
2323
* multi-tenant scenarios that need fully independent state (providers, hooks, evaluation context,
24-
* event handlers, transaction context propagators), instantiate a new instance directly with
25-
* {@code new OpenFeatureAPI()}.
24+
* event handlers, transaction context propagators), create isolated instances via
25+
* {@code dev.openfeature.sdk.isolated.OpenFeatureAPIFactory.createAPI()}.
2626
*
2727
* <p><strong>Note:</strong> Isolated API instances (per spec section 1.8) are experimental and
2828
* subject to change.
@@ -51,15 +51,24 @@ public class OpenFeatureAPI implements EventBus<OpenFeatureAPI> {
5151
private TransactionContextPropagator transactionContextPropagator;
5252

5353
/**
54-
* Creates a new, independent {@link OpenFeatureAPI} instance with fully isolated state
55-
* (providers, hooks, evaluation context, event handlers, transaction context propagators).
54+
* Creates and returns a new, independent {@link OpenFeatureAPI} instance with fully isolated
55+
* state (providers, hooks, evaluation context, event handlers, transaction context
56+
* propagators).
5657
*
57-
* <p>For typical usage, prefer the global singleton via {@link #getInstance()}.
58+
* <p>Prefer {@code OpenFeatureAPIFactory.createAPI()} from
59+
* {@code dev.openfeature.sdk.isolated}, which satisfies spec requirement 1.8.3
60+
* (factory in a distinct package for intentional discoverability).
5861
*
59-
* <p><strong>Note:</strong> Isolated API instances (per spec section 1.8) are experimental and
60-
* subject to change.
62+
* @apiNote This API is experimental and subject to change.
63+
* @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">
64+
* Spec &sect;1.8 &mdash; Isolated API Instances</a>
6165
*/
62-
public OpenFeatureAPI() {
66+
public static OpenFeatureAPI createIsolated() {
67+
return new OpenFeatureAPI();
68+
}
69+
70+
// Package-private: not part of the public API; use createIsolated() or OpenFeatureAPIFactory.
71+
OpenFeatureAPI() {
6372
this(new AutoCloseableReentrantReadWriteLock());
6473
}
6574

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package dev.openfeature.sdk.isolated;
2+
3+
import dev.openfeature.sdk.OpenFeatureAPI;
4+
5+
/**
6+
* Factory for creating isolated OpenFeature API instances.
7+
*
8+
* <p>Each instance returned by {@link #createAPI()} maintains its own state,
9+
* including providers, evaluation context, hooks, event handlers, and
10+
* transaction context propagators. Instances do not share state with the
11+
* global singleton ({@link OpenFeatureAPI#getInstance()}) or with each other.
12+
*
13+
* <p>This class lives in a distinct package ({@code dev.openfeature.sdk.isolated})
14+
* to make isolated instances intentionally less discoverable than the global
15+
* singleton, reducing the chance of accidental use when the singleton would be
16+
* appropriate.
17+
*
18+
* <p>This is useful for dependency injection frameworks, testing scenarios,
19+
* and applications composed of multiple submodules requiring distinct providers.
20+
*
21+
* <p><strong>Spec references:</strong>
22+
* <ul>
23+
* <li>Requirement 1.8.1 &mdash; factory function for isolated instances</li>
24+
* <li>Requirement 1.8.3 &mdash; factory in a distinct package/module</li>
25+
* </ul>
26+
*
27+
* @apiNote This API is experimental and subject to change.
28+
* @see <a href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">
29+
* Spec &sect;1.8 &mdash; Isolated API Instances</a>
30+
*/
31+
public final class OpenFeatureAPIFactory {
32+
33+
private OpenFeatureAPIFactory() {
34+
// utility class
35+
}
36+
37+
/**
38+
* Creates a new, independent {@link OpenFeatureAPI} instance with fully
39+
* isolated state.
40+
*
41+
* <p>Usage:
42+
* <pre>{@code
43+
* OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI();
44+
* api.setProvider(new MyProvider());
45+
* Client client = api.getClient();
46+
* }</pre>
47+
*
48+
* @apiNote This API is experimental and subject to change.
49+
* @return a new API instance
50+
*/
51+
public static OpenFeatureAPI createAPI() {
52+
return OpenFeatureAPI.createIsolated();
53+
}
54+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package dev.openfeature.sdk;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatCode;
5+
import static org.mockito.ArgumentMatchers.contains;
6+
import static org.mockito.Mockito.never;
7+
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Test;
10+
import org.mockito.Mockito;
11+
import org.simplify4u.slf4jmock.LoggerMock;
12+
import org.slf4j.Logger;
13+
14+
class GlobalProviderRegistryTest {
15+
16+
/**
17+
* Re-registering a provider with the same API instance should not produce a warning.
18+
* This exercises the {@code existing == this} path in registerGlobalProvider.
19+
*/
20+
@Test
21+
@DisplayName("no warning when same API instance re-registers the same provider")
22+
void noWarningWhenSameInstanceReRegisters() {
23+
Logger mockLogger = Mockito.mock(Logger.class);
24+
LoggerMock.setMock(OpenFeatureAPI.class, mockLogger);
25+
try {
26+
OpenFeatureAPI api = new OpenFeatureAPI();
27+
NoOpProvider provider = new NoOpProvider();
28+
29+
api.registerGlobalProvider(provider);
30+
api.registerGlobalProvider(provider); // same instance, second call
31+
32+
Mockito.verify(mockLogger, never()).warn(Mockito.anyString());
33+
} finally {
34+
LoggerMock.setMock(OpenFeatureAPI.class, null);
35+
}
36+
}
37+
38+
/**
39+
* After deregistering a provider, binding it to a different API instance
40+
* should not produce a warning — proving the entry was removed.
41+
*/
42+
@Test
43+
@DisplayName("deregister removes provider from global registry")
44+
void deregisterRemovesProviderFromRegistry() {
45+
Logger mockLogger = Mockito.mock(Logger.class);
46+
LoggerMock.setMock(OpenFeatureAPI.class, mockLogger);
47+
try {
48+
OpenFeatureAPI api1 = new OpenFeatureAPI();
49+
OpenFeatureAPI api2 = new OpenFeatureAPI();
50+
NoOpProvider provider = new NoOpProvider();
51+
52+
api1.registerGlobalProvider(provider);
53+
api1.deregisterGlobalProvider(provider);
54+
55+
// Should not warn because the provider was deregistered
56+
api2.registerGlobalProvider(provider);
57+
58+
Mockito.verify(mockLogger, never()).warn(Mockito.anyString());
59+
} finally {
60+
LoggerMock.setMock(OpenFeatureAPI.class, null);
61+
}
62+
}
63+
64+
/**
65+
* Deregister is a no-op if the calling instance is not the current owner.
66+
* The original owner's registration should remain intact.
67+
*/
68+
@Test
69+
@DisplayName("deregister is a no-op when called by non-owner instance")
70+
void deregisterIsNoOpForNonOwner() {
71+
Logger mockLogger = Mockito.mock(Logger.class);
72+
LoggerMock.setMock(OpenFeatureAPI.class, mockLogger);
73+
try {
74+
OpenFeatureAPI api1 = new OpenFeatureAPI();
75+
OpenFeatureAPI api2 = new OpenFeatureAPI();
76+
NoOpProvider provider = new NoOpProvider();
77+
78+
api1.registerGlobalProvider(provider);
79+
80+
// api2 is not the owner — this should be a no-op
81+
api2.deregisterGlobalProvider(provider);
82+
83+
// api2 re-registering should still warn, because api1 still owns it
84+
api2.registerGlobalProvider(provider);
85+
Mockito.verify(mockLogger).warn(contains("1.8.4"));
86+
} finally {
87+
LoggerMock.setMock(OpenFeatureAPI.class, null);
88+
}
89+
}
90+
91+
/**
92+
* Calling shutdown() twice on an API instance should be safe (idempotent).
93+
* The second call returns early because prepareShutdown returns null.
94+
*/
95+
@Test
96+
@DisplayName("double shutdown on API instance is safe")
97+
void doubleShutdownIsSafe() {
98+
OpenFeatureAPI api = new OpenFeatureAPI();
99+
api.setProvider(new NoOpProvider());
100+
101+
assertThatCode(() -> {
102+
api.shutdown();
103+
api.shutdown();
104+
})
105+
.doesNotThrowAnyException();
106+
}
107+
}

src/test/java/dev/openfeature/sdk/isolated/IsolatedAPISingeltonTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import dev.openfeature.sdk.NoOpTransactionContextPropagator;
88
import dev.openfeature.sdk.OpenFeatureAPI;
99
import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator;
10+
import dev.openfeature.sdk.isolated.OpenFeatureAPIFactory;
1011
import dev.openfeature.sdk.providers.memory.InMemoryProvider;
1112
import java.util.Map;
1213
import org.junit.jupiter.api.AfterEach;
@@ -35,7 +36,7 @@ void isolatedInstanceDoesNotInterfereWithSingleton() {
3536
// record singleton baseline
3637
FeatureProvider singletonProvider = singleton.getProvider();
3738

38-
OpenFeatureAPI isolated = new OpenFeatureAPI();
39+
OpenFeatureAPI isolated = OpenFeatureAPIFactory.createAPI();
3940
assertThat(isolated).isNotSameAs(singleton);
4041

4142
// mutate only the isolated instance
@@ -56,7 +57,7 @@ void isolatedInstanceDoesNotInterfereWithSingleton() {
5657
@Test
5758
@DisplayName("singleton does not interfere with isolated instance")
5859
void singletonDoesNotInterfereWithIsolatedInstance() {
59-
OpenFeatureAPI isolated = new OpenFeatureAPI();
60+
OpenFeatureAPI isolated = OpenFeatureAPIFactory.createAPI();
6061

6162
// record isolated baseline
6263
FeatureProvider isolatedProvider = isolated.getProvider();

src/test/java/dev/openfeature/sdk/isolated/IsolatedAPITest.java

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import dev.openfeature.sdk.NoOpTransactionContextPropagator;
99
import dev.openfeature.sdk.OpenFeatureAPI;
1010
import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator;
11+
import dev.openfeature.sdk.isolated.OpenFeatureAPIFactory;
1112
import dev.openfeature.sdk.providers.memory.Flag;
1213
import dev.openfeature.sdk.providers.memory.InMemoryProvider;
1314
import java.util.Map;
@@ -30,8 +31,8 @@ class IsolatedAPITest {
3031
@Test
3132
@DisplayName("factory creates distinct API instances")
3233
void factoryCreatesDistinctInstances() {
33-
OpenFeatureAPI api1 = new OpenFeatureAPI();
34-
OpenFeatureAPI api2 = new OpenFeatureAPI();
34+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
35+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
3536

3637
assertThat(api1).isInstanceOf(OpenFeatureAPI.class).isNotSameAs(api2);
3738
}
@@ -42,8 +43,8 @@ void factoryCreatesDistinctInstances() {
4243
@Test
4344
@DisplayName("providers are isolated between instances")
4445
void providerIsolation() {
45-
OpenFeatureAPI api1 = new OpenFeatureAPI();
46-
OpenFeatureAPI api2 = new OpenFeatureAPI();
46+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
47+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
4748

4849
InMemoryProvider provider = new InMemoryProvider(Map.of());
4950
api1.setProvider(provider);
@@ -58,8 +59,8 @@ void providerIsolation() {
5859
@Test
5960
@DisplayName("hooks are isolated between instances")
6061
void hookIsolation() {
61-
OpenFeatureAPI api1 = new OpenFeatureAPI();
62-
OpenFeatureAPI api2 = new OpenFeatureAPI();
62+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
63+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
6364

6465
api1.addHooks(new NoOpHook());
6566

@@ -73,8 +74,8 @@ void hookIsolation() {
7374
@Test
7475
@DisplayName("evaluation context is isolated between instances")
7576
void evaluationContextIsolation() {
76-
OpenFeatureAPI api1 = new OpenFeatureAPI();
77-
OpenFeatureAPI api2 = new OpenFeatureAPI();
77+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
78+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
7879

7980
api1.setEvaluationContext(new ImmutableContext("key-1"));
8081
api2.setEvaluationContext(new ImmutableContext("key-2"));
@@ -90,8 +91,8 @@ void evaluationContextIsolation() {
9091
@Timeout(value = 2, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
9192
@DisplayName("event handlers are isolated between instances")
9293
void eventHandlerIsolation() throws Exception {
93-
OpenFeatureAPI api1 = new OpenFeatureAPI();
94-
OpenFeatureAPI api2 = new OpenFeatureAPI();
94+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
95+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
9596

9697
CountDownLatch api1HandlerLatch = new CountDownLatch(1);
9798
AtomicBoolean api2HandlerCalled = new AtomicBoolean(false);
@@ -119,8 +120,8 @@ void eventHandlerIsolation() throws Exception {
119120
@Test
120121
@DisplayName("transaction context propagator is isolated between instances")
121122
void transactionContextPropagatorIsolation() {
122-
OpenFeatureAPI api1 = new OpenFeatureAPI();
123-
OpenFeatureAPI api2 = new OpenFeatureAPI();
123+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
124+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
124125

125126
ThreadLocalTransactionContextPropagator propagator = new ThreadLocalTransactionContextPropagator();
126127
api1.setTransactionContextPropagator(propagator);
@@ -136,7 +137,7 @@ void transactionContextPropagatorIsolation() {
136137
@Test
137138
@DisplayName("isolated instance conforms to API contract")
138139
void isolatedInstanceConformsToAPIContract() throws Exception {
139-
OpenFeatureAPI api = new OpenFeatureAPI();
140+
OpenFeatureAPI api = OpenFeatureAPIFactory.createAPI();
140141

141142
// provider management
142143
InMemoryProvider provider = new InMemoryProvider(Map.of(
@@ -171,8 +172,8 @@ void isolatedInstanceConformsToAPIContract() throws Exception {
171172
@Test
172173
@DisplayName("clearHooks does not affect other instances")
173174
void clearHooksDoesNotAffectOtherInstances() {
174-
OpenFeatureAPI api1 = new OpenFeatureAPI();
175-
OpenFeatureAPI api2 = new OpenFeatureAPI();
175+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
176+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
176177

177178
NoOpHook hook = new NoOpHook();
178179
api1.addHooks(hook);
@@ -191,8 +192,8 @@ void clearHooksDoesNotAffectOtherInstances() {
191192
@Test
192193
@DisplayName("clients use their own instance's provider")
193194
void clientUsesItsOwnInstanceProvider() throws Exception {
194-
OpenFeatureAPI api1 = new OpenFeatureAPI();
195-
OpenFeatureAPI api2 = new OpenFeatureAPI();
195+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
196+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
196197

197198
api1.setProviderAndWait(new InMemoryProvider(Map.of(
198199
"flag1",
@@ -220,8 +221,8 @@ void warnWhenProviderBoundToMultipleInstances() {
220221
Logger mockLogger = Mockito.mock(Logger.class);
221222
LoggerMock.setMock(OpenFeatureAPI.class, mockLogger);
222223
try {
223-
OpenFeatureAPI api1 = new OpenFeatureAPI();
224-
OpenFeatureAPI api2 = new OpenFeatureAPI();
224+
OpenFeatureAPI api1 = OpenFeatureAPIFactory.createAPI();
225+
OpenFeatureAPI api2 = OpenFeatureAPIFactory.createAPI();
225226

226227
NoOpProvider provider = new NoOpProvider();
227228
api1.setProvider(provider);

0 commit comments

Comments
 (0)