Skip to content

Commit 7c0403e

Browse files
authored
Merge pull request #171 from bearsunday/coroutine-safe
Add ServerContextInterface for coroutine-safe request handling
2 parents 73c3ec6 + 82fd020 commit 7c0403e

11 files changed

Lines changed: 238 additions & 8 deletions

src/GlobalServerContext.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
use Override;
8+
9+
use function is_string;
10+
11+
/**
12+
* Server context implementation using $_SERVER superglobal
13+
*
14+
* This is the default implementation for traditional PHP-FPM environments
15+
* where each request runs in isolation and $_SERVER is safe to use.
16+
*
17+
* For concurrent request processing (Swoole, RoadRunner), use a request-scoped
18+
* implementation instead.
19+
*/
20+
final class GlobalServerContext implements ServerContextInterface
21+
{
22+
#[Override]
23+
public function get(string $key): string|null
24+
{
25+
if (! isset($_SERVER[$key])) {
26+
return null;
27+
}
28+
29+
/** @var mixed $value */
30+
$value = $_SERVER[$key];
31+
32+
return is_string($value) ? $value : null;
33+
}
34+
35+
#[Override]
36+
public function has(string $key): bool
37+
{
38+
return isset($_SERVER[$key]);
39+
}
40+
}

src/QueryRepositoryModule.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ protected function configure(): void
6969
$this->bind(RefreshAnnotatedCommand::class);
7070
$this->bind(RefreshSameCommand::class);
7171
$this->bind(ResourceStorageSaver::class);
72+
// Server context for thread safety (Swoole, RoadRunner, etc.)
73+
$this->bind(ServerContextInterface::class)->to(GlobalServerContext::class)->in(Scope::SINGLETON);
7274
// #[Cacheable]
7375
$this->install(new CacheableModule());
7476
// #[CacheableResponse]

src/RepositoryLogger.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ public function log(string $operation, array $context = []): void
2828
$this->logs[] = ['op' => $operation, ...$context];
2929
}
3030

31+
/**
32+
* {@inheritDoc}
33+
*/
34+
#[Override]
35+
public function reset(): void
36+
{
37+
$this->logs = [];
38+
}
39+
3140
#[Override]
3241
public function __toString(): string
3342
{

src/RepositoryLoggerInterface.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,14 @@ interface RepositoryLoggerInterface
99
/** @param array<string, mixed> $context */
1010
public function log(string $operation, array $context = []): void;
1111

12+
/**
13+
* Reset the logger state
14+
*
15+
* This method clears all accumulated logs. It should be called at the end of
16+
* each request in long-running environments (Swoole, RoadRunner) to prevent
17+
* log accumulation across requests.
18+
*/
19+
public function reset(): void;
20+
1221
public function __toString(): string;
1322
}

src/ResourceStorage.php

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
use function explode;
2222
use function implode;
2323
use function is_array;
24-
use function is_string;
2524
use function sprintf;
2625
use function strtoupper;
26+
use function trim;
2727

2828
/**
2929
* @psalm-type Props = array{
@@ -32,7 +32,8 @@
3232
* uriTag: UriTag,
3333
* saver: ResourceStorageSaver,
3434
* roProvider:ProviderInterface<TagAwareAdapterInterface>,
35-
* etagProvider: ProviderInterface<TagAwareAdapterInterface>
35+
* etagProvider: ProviderInterface<TagAwareAdapterInterface>,
36+
* serverContext: ServerContextInterface
3637
* }
3738
*/
3839
final class ResourceStorage implements ResourceStorageInterface
@@ -64,6 +65,7 @@ public function __construct(
6465
private PurgerInterface $purger,
6566
private UriTagInterface $uriTag,
6667
private ResourceStorageSaver $saver,
68+
private ServerContextInterface $serverContext,
6769
#[Set(TagAwareAdapterInterface::class, ResourceObjectPool::class)]
6870
ProviderInterface $roPoolProvider,
6971
#[Set(TagAwareAdapterInterface::class, EtagPool::class)]
@@ -239,19 +241,26 @@ private function evaluateBody(mixed $body): mixed
239241

240242
private function getUriKey(AbstractUri $uri, string $type): string
241243
{
242-
return $type . ($this->uriTag)($uri) . (isset($_SERVER['X_VARY']) ? $this->getVary() : '');
244+
return $type . ($this->uriTag)($uri) . ($this->serverContext->has('X_VARY') ? $this->getVary() : '');
243245
}
244246

245247
private function getVary(): string
246248
{
247-
$xvary = $_SERVER['X_VARY'];
248-
/** @psalm-suppress RedundantCast */
249-
$varys = explode(',', (string) $xvary); // @phpstan-ignore-line
249+
$xvary = $this->serverContext->get('X_VARY');
250+
assert($xvary !== null, 'getVary() is only called when X_VARY exists');
251+
252+
$varys = explode(',', $xvary);
250253
$varyString = '';
251254
foreach ($varys as $vary) {
255+
$vary = trim($vary);
256+
if ($vary === '') {
257+
continue;
258+
}
259+
252260
$phpVaryKey = sprintf('X_%s', strtoupper($vary));
253-
if (isset($_SERVER[$phpVaryKey]) && is_string($_SERVER[$phpVaryKey])) {
254-
$varyString .= $_SERVER[$phpVaryKey];
261+
$value = $this->serverContext->get($phpVaryKey);
262+
if ($value !== null) {
263+
$varyString .= $value;
255264
}
256265
}
257266

@@ -279,6 +288,7 @@ public function __serialize(): array
279288
'saver' => $this->saver,
280289
'roProvider' => $this->roPoolProvider,
281290
'etagProvider' => $this->etagPoolProvider,
291+
'serverContext' => $this->serverContext,
282292
];
283293
}
284294

@@ -293,6 +303,7 @@ public function __unserialize(array $data): void
293303
$this->purger = $data['purger'];
294304
$this->uriTag = $data['uriTag'];
295305
$this->saver = $data['saver'];
306+
$this->serverContext = $data['serverContext'];
296307
$this->initializePools($data['roProvider'], $data['etagProvider']);
297308
}
298309
}

src/ServerContextInterface.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
/**
8+
* Provides access to server context in a thread-safe manner
9+
*
10+
* This interface abstracts access to request context (like $_SERVER) to support
11+
* concurrent request processing in environments like Swoole, RoadRunner, and ReactPHP.
12+
*
13+
* For traditional PHP-FPM: Use GlobalServerContext (default)
14+
* For Swoole/RoadRunner: Implement with request-scoped context
15+
*/
16+
interface ServerContextInterface
17+
{
18+
/**
19+
* Get a value from the server context
20+
*
21+
* @param string $key The key to retrieve (e.g., 'HTTP_USER_AGENT', 'X_VARY')
22+
*
23+
* @return string|null The value or null if not set
24+
*/
25+
public function get(string $key): string|null;
26+
27+
/**
28+
* Check if a key exists in the server context
29+
*
30+
* @param string $key The key to check
31+
*/
32+
public function has(string $key): bool;
33+
}

tests/GetInterceptorTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,15 @@ public function testHttpCacheVary(): void
101101

102102
unset($_SERVER['X_VARY'], $_SERVER['X_VAL1'], $_SERVER['X_VAL2']);
103103
}
104+
105+
public function testHttpCacheVaryWithEmptySegment(): void
106+
{
107+
$_SERVER['X_VARY'] = 'val1, , val2';
108+
$_SERVER['X_VAL1'] = '1';
109+
$_SERVER['X_VAL2'] = '2';
110+
$ro = $this->resource->get('app://self/etag');
111+
$this->assertArrayNotHasKey('Age', $ro->headers);
112+
113+
unset($_SERVER['X_VARY'], $_SERVER['X_VAL1'], $_SERVER['X_VAL2']);
114+
}
104115
}

tests/GlobalServerContextTest.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
use PHPUnit\Framework\TestCase;
8+
9+
use function array_key_exists;
10+
11+
class GlobalServerContextTest extends TestCase
12+
{
13+
private GlobalServerContext $context;
14+
15+
/** @var array<string, mixed> */
16+
private array $originalServer = [];
17+
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
22+
// Save original values for keys we'll modify
23+
foreach (['TEST_KEY', 'TEST_STRING', 'TEST_INT', 'HTTP_USER_AGENT', 'X_VARY'] as $key) {
24+
if (array_key_exists($key, $_SERVER)) {
25+
$this->originalServer[$key] = $_SERVER[$key];
26+
}
27+
}
28+
29+
$this->context = new GlobalServerContext();
30+
}
31+
32+
protected function tearDown(): void
33+
{
34+
// Restore original values or unset if they didn't exist
35+
foreach (['TEST_KEY', 'TEST_STRING', 'TEST_INT', 'HTTP_USER_AGENT', 'X_VARY'] as $key) {
36+
if (array_key_exists($key, $this->originalServer)) {
37+
$_SERVER[$key] = $this->originalServer[$key];
38+
} else {
39+
unset($_SERVER[$key]);
40+
}
41+
}
42+
43+
parent::tearDown();
44+
}
45+
46+
public function testGetReturnsValue(): void
47+
{
48+
$_SERVER['TEST_KEY'] = 'test_value';
49+
50+
$this->assertSame('test_value', $this->context->get('TEST_KEY'));
51+
}
52+
53+
public function testGetReturnsNullForNonExistentKey(): void
54+
{
55+
$this->assertNull($this->context->get('NON_EXISTENT_KEY'));
56+
}
57+
58+
public function testGetReturnsNullForNonStringValue(): void
59+
{
60+
$_SERVER['TEST_INT'] = 123;
61+
62+
$this->assertNull($this->context->get('TEST_INT'));
63+
}
64+
65+
public function testHasReturnsTrueForExistingKey(): void
66+
{
67+
$_SERVER['TEST_KEY'] = 'value';
68+
69+
$this->assertTrue($this->context->has('TEST_KEY'));
70+
}
71+
72+
public function testHasReturnsFalseForNonExistentKey(): void
73+
{
74+
$this->assertFalse($this->context->has('NON_EXISTENT_KEY'));
75+
}
76+
77+
public function testGetHttpUserAgent(): void
78+
{
79+
$userAgent = 'Mozilla/5.0 Test';
80+
$_SERVER['HTTP_USER_AGENT'] = $userAgent;
81+
82+
$this->assertSame($userAgent, $this->context->get('HTTP_USER_AGENT'));
83+
}
84+
85+
public function testGetXVary(): void
86+
{
87+
$_SERVER['X_VARY'] = 'val1,val2';
88+
89+
$this->assertSame('val1,val2', $this->context->get('X_VARY'));
90+
}
91+
}

tests/RepositoryLoggerTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,25 @@ public function testLogWithNullValue(): void
6565
$logger->log('save-donut', ['uri' => 'app://self/page', 'sMaxAge' => null]);
6666
$this->assertSame('{"op":"save-donut","uri":"app://self/page","sMaxAge":null}', (string) $logger);
6767
}
68+
69+
public function testReset(): void
70+
{
71+
$logger = new RepositoryLogger();
72+
$logger->log('operation1', ['id' => 1]);
73+
$logger->log('operation2', ['id' => 2]);
74+
$this->assertNotEmpty((string) $logger);
75+
76+
$logger->reset();
77+
$this->assertSame('', (string) $logger);
78+
}
79+
80+
public function testResetAllowsNewLogs(): void
81+
{
82+
$logger = new RepositoryLogger();
83+
$logger->log('old-operation');
84+
$logger->reset();
85+
$logger->log('new-operation');
86+
87+
$this->assertSame('{"op":"new-operation"}', (string) $logger);
88+
}
6889
}

tests/ResourceRepositoryTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function get()
4444
new NullPurger(),
4545
new UriTag(),
4646
new ResourceStorageSaver(),
47+
new GlobalServerContext(),
4748
$tagAwareAdapterProvider,
4849
$tagAwareAdapterProvider,
4950
),
@@ -106,6 +107,7 @@ public function get()
106107
new NullPurger(),
107108
new UriTag(),
108109
new ResourceStorageSaver(),
110+
new GlobalServerContext(),
109111
$tagAwareAdapterProvider,
110112
$tagAwareAdapterProvider,
111113
),

0 commit comments

Comments
 (0)