Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions packages/core/src/RequestStateResetter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Marko\Core\Container\ContainerInterface;
use Marko\Core\Contracts\ResettableInterface;
use Marko\Core\Plugin\PluginInterceptedInterface;

/**
* Clears request-scoped state between requests served by one booted
Expand Down Expand Up @@ -38,16 +39,32 @@ public function __construct(
public function reset(
ResettableInterface ...$except,
): void {
/** @var array<string, ResettableInterface> $resettables */
$resettables = $this->container->resolvedInstances(ResettableInterface::class);
ksort($resettables);
$instances = $this->container->resolvedInstances();
ksort($instances);

foreach ($resettables as $resettable) {
if (in_array($resettable, $except, true)) {
foreach ($instances as $instance) {
$resettable = $this->resettableBehind($instance);

if ($resettable === null || in_array($resettable, $except, true)) {
continue;
}

$resettable->reset();
}
}

/**
* A plugin interface proxy implements only the plugged interface, so the
* resettable service it wraps would otherwise keep one request's state.
* A concrete subclass proxy is itself resettable and holds the state.
*/
private function resettableBehind(
object $instance,
): ?ResettableInterface {
while ($instance instanceof PluginInterceptedInterface && !$instance instanceof ResettableInterface) {
$instance = $instance->getPluginTarget();
}

return $instance instanceof ResettableInterface ? $instance : null;
}
}
124 changes: 124 additions & 0 deletions packages/core/tests/Unit/RequestStateResetterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,79 @@

use Marko\Core\Container\Container;
use Marko\Core\Contracts\ResettableInterface;
use Marko\Core\Plugin\InterceptorClassGenerator;
use Marko\Core\Plugin\PluginDefinition;
use Marko\Core\Plugin\PluginInterceptedInterface;
use Marko\Core\Plugin\PluginInterceptor;
use Marko\Core\Plugin\PluginRegistry;
use Marko\Core\RequestStateResetter;

interface ResetterCurrentUserInterface
{
public function name(): ?string;

public function remember(
string $name,
): void;
}

/**
* Holds per-request state behind an interface, the way SessionGuard does.
*/
class ResetterCurrentUser implements ResetterCurrentUserInterface, ResettableInterface
{
private ?string $name = null;

public function name(): ?string
{
return $this->name;
}

public function remember(
string $name,
): void {
$this->name = $name;
}

public function reset(): void
{
$this->name = null;
}
}

class ResetterCurrentUserPlugin
{
public function beforeName(): ?array
{
return null;
}
}

/**
* A container that wraps the shared $targetClass service in a plugin proxy:
* an interface wrapper for an interface, a subclass for a class.
*
* @param class-string $targetClass
*/
function resetterContainerPlugging(
string $targetClass,
): Container {
$container = new Container();
$registry = new PluginRegistry();
$registry->register(new PluginDefinition(
pluginClass: ResetterCurrentUserPlugin::class,
targetClass: $targetClass,
beforeMethods: ['name' => ['pluginMethod' => 'beforeName', 'sortOrder' => 10]],
));
$container->setPluginInterceptor(
new PluginInterceptor($container, $registry, new InterceptorClassGenerator()),
);
$container->bind(ResetterCurrentUserInterface::class, ResetterCurrentUser::class);
$container->singleton($targetClass);

return $container;
}

function requestStateResetterSpy(
array &$log,
string $label,
Expand Down Expand Up @@ -74,6 +145,59 @@ public function reset(): void

new RequestStateResetter($container)->reset();
})->throws(RuntimeException::class, 'reset failed');
it('resets a resettable service that a plugin interface proxy wraps', function (): void {
$container = resetterContainerPlugging(ResetterCurrentUserInterface::class);
$user = $container->get(ResetterCurrentUserInterface::class);
$user->remember('Etienne Aaron Scherrer');

new RequestStateResetter($container)->reset();

expect($user)->toBeInstanceOf(PluginInterceptedInterface::class)
->and($user->name())->toBeNull();
});

it('resets a resettable service that a plugin subclass proxy replaces', function (): void {
$container = resetterContainerPlugging(ResetterCurrentUser::class);
$user = $container->get(ResetterCurrentUser::class);
$user->remember('Etienne Aaron Scherrer');

new RequestStateResetter($container)->reset();

expect($user)->toBeInstanceOf(PluginInterceptedInterface::class)
->and($user->name())->toBeNull();
});

it('resets a resettable service behind a proxy that wraps another proxy', function (): void {
$container = new Container();
$registry = new PluginRegistry();
$registry->register(new PluginDefinition(
pluginClass: ResetterCurrentUserPlugin::class,
targetClass: ResetterCurrentUserInterface::class,
beforeMethods: ['name' => ['pluginMethod' => 'beforeName', 'sortOrder' => 10]],
));
$interceptor = new PluginInterceptor($container, $registry, new InterceptorClassGenerator());
$user = new ResetterCurrentUser();
$inner = $interceptor->createProxy(ResetterCurrentUserInterface::class, ResetterCurrentUser::class, $user);
$outer = $interceptor->createProxy(ResetterCurrentUserInterface::class, $inner::class, $inner);
$container->instance(ResetterCurrentUserInterface::class, $outer);
$user->remember('Etienne Aaron Scherrer');

new RequestStateResetter($container)->reset();

expect($outer->getPluginTarget())->toBe($inner)
->and($user->name())->toBeNull();
});

it('skips a wrapped service passed as an exception', function (): void {
$container = resetterContainerPlugging(ResetterCurrentUserInterface::class);
$user = $container->get(ResetterCurrentUserInterface::class);
$user->remember('Kira Spielmann');

new RequestStateResetter($container)->reset($user->getPluginTarget());

expect($user->name())->toBe('Kira Spielmann');
});

it('skips the instances passed as exceptions', function (): void {
$log = [];
$container = new Container();
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-markdown/docs/packages/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ $this->http->actingAs($apiUser, 'api')->getJson('/api/me')->assertOk();

### Request-scoped state and exceptions

Before each request, the client calls `reset()` on every resolved `ResettableInterface` service (`Marko\Core\RequestStateResetter`, the same code the RoadRunner worker runs), so the session, the session guard and similar request-scoped state never leak from one request to the next.
Before each request, the client calls `reset()` on every resolved `ResettableInterface` service, including one a plugin proxy wraps (`Marko\Core\RequestStateResetter`, the same code the RoadRunner worker runs), so the session, the session guard and similar request-scoped state never leak from one request to the next.

An exception thrown by a controller or middleware propagates out of `$client->get()`, so the test shows the real stack trace. HTTP exceptions (`HttpExceptionInterface`, e.g. `HttpException::notFound()`) are rendered into a response by the router, exactly as in production, so `assertNotFound()` and friends work on them.

Expand Down
Loading