Skip to content

Commit 08c9cbd

Browse files
committed
feat: support isolated API instances
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
1 parent a32f0a7 commit 08c9cbd

3 files changed

Lines changed: 242 additions & 1 deletion

File tree

src/OpenFeatureAPI.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,12 @@ public static function getInstance(): API
5555
* It's important that multiple instances of the API not be active, so that state stored therein, such as the registered provider, static global
5656
* evaluation context, and globally configured hooks allow the API to behave predictably. This can be difficult in some runtimes or languages, but
5757
* implementors should make their best effort to ensure that only a single instance of the API is used.
58+
*
59+
* For isolated instances, prefer using the factory function in OpenFeature\isolated.
60+
*
61+
* @see \OpenFeature\isolated\OpenFeatureAPIFactory::createAPI()
5862
*/
59-
private function __construct()
63+
public function __construct()
6064
{
6165
$this->provider = new NoOpProvider();
6266
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenFeature\isolated;
6+
7+
use OpenFeature\OpenFeatureAPI;
8+
use OpenFeature\interfaces\flags\API;
9+
10+
/**
11+
* Factory for creating isolated OpenFeature API instances.
12+
*
13+
* -----------------
14+
* Requirement 1.8.1
15+
* -----------------
16+
* The API MUST expose a factory function which creates and returns a new,
17+
* independent instance of the API.
18+
*
19+
* Each instance returned by this factory function maintains its own state,
20+
* including providers, evaluation context, hooks, and event handlers.
21+
* Instances created by the factory function do not share state with the
22+
* "default" global singleton or with each other.
23+
*
24+
* -----------------
25+
* Requirement 1.8.3
26+
* -----------------
27+
* The factory function for creating isolated instances SHOULD be housed in a
28+
* distinct module, import path, package, or namespace from the global
29+
* singleton API.
30+
*
31+
* @see https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances
32+
*/
33+
final class OpenFeatureAPIFactory
34+
{
35+
/**
36+
* Creates a new, independent API instance with fully isolated state.
37+
*
38+
* Usage:
39+
* $api = OpenFeatureAPIFactory::createAPI();
40+
* $api->setProvider(new MyProvider());
41+
* $client = $api->getClient();
42+
*/
43+
public static function createAPI(): API
44+
{
45+
return new OpenFeatureAPI();
46+
}
47+
}

tests/unit/IsolatedAPITest.php

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenFeature\Test\unit;
6+
7+
use OpenFeature\OpenFeatureAPI;
8+
use OpenFeature\Test\TestCase;
9+
use OpenFeature\Test\TestHook;
10+
use OpenFeature\Test\TestProvider;
11+
use OpenFeature\implementation\flags\EvaluationContext;
12+
use OpenFeature\implementation\provider\NoOpProvider;
13+
use OpenFeature\interfaces\flags\API;
14+
use OpenFeature\isolated\OpenFeatureAPIFactory;
15+
16+
class IsolatedAPITest extends TestCase
17+
{
18+
/**
19+
* Requirement 1.8.1
20+
*
21+
* The API MUST expose a factory function which creates and returns a new,
22+
* independent instance of the API.
23+
*/
24+
public function testFactoryCreatesNewAPIInstance(): void
25+
{
26+
$api = OpenFeatureAPIFactory::createAPI();
27+
28+
$this->assertInstanceOf(API::class, $api);
29+
$this->assertInstanceOf(OpenFeatureAPI::class, $api);
30+
}
31+
32+
/**
33+
* Requirement 1.8.1
34+
*
35+
* Each instance returned by this factory function maintains its own state.
36+
* Instances created by the factory function do not share state with the
37+
* "default" global singleton or with each other.
38+
*/
39+
public function testFactoryCreatesDistinctInstances(): void
40+
{
41+
$api1 = OpenFeatureAPIFactory::createAPI();
42+
$api2 = OpenFeatureAPIFactory::createAPI();
43+
44+
$this->assertNotSame($api1, $api2);
45+
}
46+
47+
/**
48+
* Requirement 1.8.1
49+
*
50+
* Instances do not share state with the "default" global singleton.
51+
*/
52+
public function testIsolatedInstanceIsNotTheSingleton(): void
53+
{
54+
$singleton = OpenFeatureAPI::getInstance();
55+
$isolated = OpenFeatureAPIFactory::createAPI();
56+
57+
$this->assertNotSame($singleton, $isolated);
58+
}
59+
60+
/**
61+
* Requirement 1.8.2
62+
*
63+
* Instances returned by the factory function MUST conform to the same API
64+
* contract as the global singleton, including flag evaluation, provider
65+
* management, context, hooks, events, and shutdown functionality.
66+
*/
67+
public function testIsolatedInstanceConformsToAPIContract(): void
68+
{
69+
$api = OpenFeatureAPIFactory::createAPI();
70+
71+
// Provider management
72+
$provider = new TestProvider();
73+
$api->setProvider($provider);
74+
$this->assertSame($provider, $api->getProvider());
75+
$this->assertEquals($provider->getMetadata(), $api->getProviderMetadata());
76+
77+
// Hooks
78+
$hook = new TestHook();
79+
$api->addHooks($hook);
80+
$this->assertEquals([$hook], $api->getHooks());
81+
82+
// Evaluation context
83+
$context = new EvaluationContext('targeting-key');
84+
$api->setEvaluationContext($context);
85+
$this->assertSame($context, $api->getEvaluationContext());
86+
87+
// Client creation
88+
$client = $api->getClient('test-domain', '1.0.0');
89+
$this->assertEquals('test-domain', $client->getMetadata()->getName());
90+
}
91+
92+
/**
93+
* Requirement 1.8.1
94+
*
95+
* Providers are isolated between instances.
96+
*/
97+
public function testProviderIsolation(): void
98+
{
99+
$singleton = OpenFeatureAPI::getInstance();
100+
$singleton->setProvider(new NoOpProvider());
101+
102+
$isolated = OpenFeatureAPIFactory::createAPI();
103+
$isolated->setProvider(new TestProvider());
104+
105+
$this->assertInstanceOf(NoOpProvider::class, $singleton->getProvider());
106+
$this->assertInstanceOf(TestProvider::class, $isolated->getProvider());
107+
}
108+
109+
/**
110+
* Requirement 1.8.1
111+
*
112+
* Hooks are isolated between instances.
113+
*/
114+
public function testHookIsolation(): void
115+
{
116+
$singleton = OpenFeatureAPI::getInstance();
117+
$singleton->clearHooks();
118+
119+
$isolated = OpenFeatureAPIFactory::createAPI();
120+
$hook = new TestHook();
121+
$isolated->addHooks($hook);
122+
123+
$this->assertEmpty($singleton->getHooks());
124+
$this->assertCount(1, $isolated->getHooks());
125+
}
126+
127+
/**
128+
* Requirement 1.8.1
129+
*
130+
* Evaluation context is isolated between instances.
131+
*/
132+
public function testEvaluationContextIsolation(): void
133+
{
134+
$singleton = OpenFeatureAPI::getInstance();
135+
$singletonContext = new EvaluationContext('singleton-key');
136+
$singleton->setEvaluationContext($singletonContext);
137+
138+
$isolated = OpenFeatureAPIFactory::createAPI();
139+
$isolatedContext = new EvaluationContext('isolated-key');
140+
$isolated->setEvaluationContext($isolatedContext);
141+
142+
$actualSingletonContext = $singleton->getEvaluationContext();
143+
$actualIsolatedContext = $isolated->getEvaluationContext();
144+
145+
$this->assertNotNull($actualSingletonContext);
146+
$this->assertNotNull($actualIsolatedContext);
147+
$this->assertEquals('singleton-key', $actualSingletonContext->getTargetingKey());
148+
$this->assertEquals('isolated-key', $actualIsolatedContext->getTargetingKey());
149+
}
150+
151+
/**
152+
* Requirement 1.8.2
153+
*
154+
* An isolated API instance is functionally equivalent to the global
155+
* singleton. A client obtained from an isolated instance behaves
156+
* identically to a client from the global singleton.
157+
*/
158+
public function testClientFromIsolatedInstanceUsesIsolatedProvider(): void
159+
{
160+
$isolated = OpenFeatureAPIFactory::createAPI();
161+
$provider = new TestProvider();
162+
$isolated->setProvider($provider);
163+
164+
$client = $isolated->getClient('test', '1.0');
165+
$result = $client->getBooleanValue('flag-key', false);
166+
167+
// TestProvider returns the default value
168+
$this->assertFalse($result);
169+
}
170+
171+
/**
172+
* Requirement 1.8.1
173+
*
174+
* clearHooks on one instance does not affect another.
175+
*/
176+
public function testClearHooksDoesNotAffectOtherInstances(): void
177+
{
178+
$api1 = OpenFeatureAPIFactory::createAPI();
179+
$api2 = OpenFeatureAPIFactory::createAPI();
180+
181+
$hook = new TestHook();
182+
$api1->addHooks($hook);
183+
$api2->addHooks($hook);
184+
185+
$api1->clearHooks();
186+
187+
$this->assertEmpty($api1->getHooks());
188+
$this->assertCount(1, $api2->getHooks());
189+
}
190+
}

0 commit comments

Comments
 (0)