Skip to content

Commit 0b20864

Browse files
committed
Make DispatchContext externally immutable
DispatchContext: - Constructor injection for RoutinePipeline, handlers, and basePath (removes setRoutinePipeline, setHandlers, setPath) - All public properties use private(set), readable externally, writable only through internal methods (configureRoute, withRequest) - Eliminate transient route mutations in matchRoute/routineMatch - Move SplObjectStorage negotiation to request attributes - Add handler state reset and error/exception isolation tests DispatchEngine: - Remove $onContextReady closure, no callback coupling - Pure stateless dispatcher: creates fully-initialized contexts - Expose routinePipeline() getter for Router::createDispatchContext Router: - Remove public $context property and __toString() - No per-request state, immutable after route registration
1 parent 4e7f75d commit 0b20864

15 files changed

Lines changed: 194 additions & 238 deletions

src/DispatchContext.php

Lines changed: 63 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
use function in_array;
2020
use function is_a;
21+
use function preg_quote;
22+
use function preg_replace;
2123
use function rawurldecode;
2224
use function rtrim;
2325
use function set_error_handler;
@@ -29,14 +31,12 @@
2931
final class DispatchContext implements ContainerInterface
3032
{
3133
/** @var array<int, mixed> */
32-
public array $params = [];
34+
public private(set) array $params = [];
3335

34-
public AbstractRoute|null $route = null;
36+
public private(set) AbstractRoute|null $route = null;
3537

3638
/** @var array<string, string> Headers to apply only when the response does not already have them */
37-
public array $defaultResponseHeaders = [];
38-
39-
private RoutinePipeline|null $routinePipeline = null;
39+
public private(set) array $defaultResponseHeaders = [];
4040

4141
private Responder|null $responder = null;
4242

@@ -49,21 +49,32 @@ final class DispatchContext implements ContainerInterface
4949

5050
private bool $hasStatusOverride = false;
5151

52-
private string $effectiveMethod = '';
53-
54-
private string $effectivePath = '';
52+
private string $effectiveMethod;
5553

56-
/** @var array<int, AbstractRoute> */
57-
private array $handlers = [];
54+
private string $effectivePath;
5855

5956
private Resolver|null $resolver = null;
6057

58+
/** @param array<int, AbstractRoute> $handlers */
6159
public function __construct(
62-
public ServerRequestInterface $request,
63-
public ResponseFactoryInterface&StreamFactoryInterface $factory,
60+
public private(set) ServerRequestInterface $request,
61+
public private(set) ResponseFactoryInterface&StreamFactoryInterface $factory,
62+
private RoutinePipeline $routinePipeline = new RoutinePipeline(),
63+
private array $handlers = [],
64+
string $basePath = '',
6465
) {
65-
$this->effectivePath = rtrim(rawurldecode($request->getUri()->getPath()), ' /');
66+
$path = rtrim(rawurldecode($request->getUri()->getPath()), ' /');
67+
if ($basePath !== '') {
68+
$path = preg_replace(
69+
'#^' . preg_quote($basePath, '#') . '#',
70+
'',
71+
$path,
72+
) ?? $path;
73+
}
74+
75+
$this->effectivePath = $path;
6676
$this->effectiveMethod = strtoupper($request->getMethod());
77+
$this->resetHandlerState();
6778
}
6879

6980
public function method(): string
@@ -76,11 +87,6 @@ public function path(): string
7687
return $this->effectivePath;
7788
}
7889

79-
public function setPath(string $path): void
80-
{
81-
$this->effectivePath = $path;
82-
}
83-
8490
public function hasPreparedResponse(): bool
8591
{
8692
return $this->hasPreparedResponse;
@@ -125,6 +131,13 @@ public function prepareResponse(int $status, array $headers = []): void
125131
}
126132
}
127133

134+
/** @param array<int, mixed> $params */
135+
public function configureRoute(AbstractRoute $route, array $params = []): void
136+
{
137+
$this->route = $route;
138+
$this->params = $params;
139+
}
140+
128141
/** Generates the PSR-7 response from the current route */
129142
public function response(): ResponseInterface|null
130143
{
@@ -146,7 +159,7 @@ public function response(): ResponseInterface|null
146159
$previousErrorHandler = $isHandler ? null : $this->installErrorHandler();
147160

148161
try {
149-
$preRoutineResult = $this->routinePipeline()->processBy($this, $route);
162+
$preRoutineResult = $this->routinePipeline->processBy($this, $route);
150163

151164
if ($preRoutineResult instanceof AbstractRoute) {
152165
return $this->forward($preRoutineResult);
@@ -166,7 +179,7 @@ public function response(): ResponseInterface|null
166179
return $this->forward($rawResult);
167180
}
168181

169-
$processedResult = $this->routinePipeline()->processThrough($this, $route, $rawResult);
182+
$processedResult = $this->routinePipeline->processThrough($this, $route, $rawResult);
170183

171184
if (!$isHandler) {
172185
$errorResponse = $this->forwardCollectedErrors();
@@ -192,27 +205,21 @@ public function response(): ResponseInterface|null
192205
}
193206
}
194207

195-
public function forward(AbstractRoute $route): ResponseInterface|null
208+
public function withRequest(ServerRequestInterface $request): void
196209
{
197-
$this->route = $route;
198-
199-
return $this->response();
210+
$this->request = $request;
200211
}
201212

202-
public function setRoutinePipeline(RoutinePipeline $routinePipeline): void
213+
public function setResponder(Responder $responder): void
203214
{
204-
$this->routinePipeline = $routinePipeline;
215+
$this->responder = $responder;
205216
}
206217

207-
/** @param array<int, AbstractRoute> $handlers */
208-
public function setHandlers(array $handlers): void
218+
public function forward(AbstractRoute $route): ResponseInterface|null
209219
{
210-
$this->handlers = $handlers;
211-
}
220+
$this->route = $route;
212221

213-
public function setResponder(Responder $responder): void
214-
{
215-
$this->responder = $responder;
222+
return $this->response();
216223
}
217224

218225
public function resolver(): Resolver
@@ -239,7 +246,26 @@ public function get(string $id): mixed
239246
throw new NotFoundException(sprintf('No entry found for "%s"', $id));
240247
}
241248

242-
/** @return callable|null The previous error handler, or null if no ErrorHandler is registered */
249+
private function resetHandlerState(): void
250+
{
251+
foreach ($this->handlers as $handler) {
252+
if ($handler instanceof ErrorHandler) {
253+
$handler->errors = [];
254+
} elseif ($handler instanceof ExceptionHandler) {
255+
$handler->exception = null;
256+
}
257+
}
258+
}
259+
260+
/**
261+
* Installs a custom error handler that collects PHP errors for the current dispatch.
262+
*
263+
* Safe for single-request-per-worker runtimes (PHP-FPM, Swoole workers,
264+
* FrankenPHP workers, ReactPHP). Not safe for coroutine-concurrent request
265+
* handling within a single PHP process, since set_error_handler is global state.
266+
*
267+
* @return callable|null The previous error handler, or null if no ErrorHandler is registered
268+
*/
243269
private function installErrorHandler(): callable|null
244270
{
245271
foreach ($this->handlers as $handler) {
@@ -303,7 +329,7 @@ private function forwardToStatusRoute(ResponseInterface $preparedResponse): Resp
303329

304330
// Run routine negotiation (e.g. Accept) before forwarding,
305331
// since the normal route-selection phase was skipped
306-
$this->routinePipeline()->matches($this, $handler, $this->params);
332+
$this->routinePipeline->matches($this, $handler, $this->params);
307333

308334
$result = $this->forward($handler);
309335

@@ -327,27 +353,14 @@ private function finalizeResponse(mixed $response): ResponseInterface
327353
);
328354
}
329355

330-
private function routinePipeline(): RoutinePipeline
331-
{
332-
return $this->routinePipeline ??= new RoutinePipeline();
333-
}
334-
335356
private function responder(): Responder
336357
{
337-
if ($this->responder !== null) {
338-
return $this->responder;
339-
}
340-
341-
return $this->responder = new Responder($this->factory);
358+
return $this->responder ??= new Responder($this->factory);
342359
}
343360

344361
private function ensureResponseDraft(): ResponseInterface
345362
{
346-
if ($this->responseDraft !== null) {
347-
return $this->responseDraft;
348-
}
349-
350-
return $this->responseDraft = $this->factory->createResponse();
363+
return $this->responseDraft ??= $this->factory->createResponse();
351364
}
352365

353366
public function __toString(): string

src/DispatchEngine.php

Lines changed: 10 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace Respect\Rest;
66

7-
use Closure;
87
use Psr\Http\Message\ResponseFactoryInterface;
98
use Psr\Http\Message\ResponseInterface;
109
use Psr\Http\Message\ServerRequestInterface;
@@ -19,28 +18,32 @@
1918
use function count;
2019
use function implode;
2120
use function iterator_to_array;
22-
use function preg_quote;
23-
use function preg_replace;
2421
use function stripos;
2522

2623
final class DispatchEngine implements RequestHandlerInterface
2724
{
2825
private RoutinePipeline $routinePipeline;
2926

30-
/** @param (Closure(DispatchContext): void)|null $onContextReady */
3127
public function __construct(
3228
private RouteProvider $routeProvider,
3329
private ResponseFactoryInterface&StreamFactoryInterface $factory,
34-
private Closure|null $onContextReady = null,
3530
) {
3631
$this->routinePipeline = new RoutinePipeline();
3732
}
3833

34+
public function routinePipeline(): RoutinePipeline
35+
{
36+
return $this->routinePipeline;
37+
}
38+
3939
public function dispatch(ServerRequestInterface $serverRequest): DispatchContext
4040
{
4141
$context = new DispatchContext(
4242
$serverRequest,
4343
$this->factory,
44+
$this->routinePipeline,
45+
$this->routeProvider->getHandlers(),
46+
$this->routeProvider->getBasePath(),
4447
);
4548

4649
return $this->dispatchContext($context);
@@ -55,13 +58,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface
5558

5659
public function dispatchContext(DispatchContext $context): DispatchContext
5760
{
58-
if ($this->onContextReady !== null) {
59-
($this->onContextReady)($context);
60-
}
61-
62-
$context->setRoutinePipeline($this->routinePipeline);
63-
$context->setHandlers($this->routeProvider->getHandlers());
64-
6561
if (!$this->isRoutelessDispatch($context) && $context->route === null) {
6662
$this->routeDispatch($context);
6763
}
@@ -120,8 +116,6 @@ private function isRoutelessDispatch(DispatchContext $context): bool
120116

121117
private function routeDispatch(DispatchContext $context): void
122118
{
123-
$this->applyBasePath($context);
124-
125119
$matchedByPath = $this->getMatchedRoutesByPath($context);
126120
/** @var array<int, AbstractRoute> $matchedArray */
127121
$matchedArray = iterator_to_array($matchedByPath);
@@ -140,30 +134,13 @@ private function routeDispatch(DispatchContext $context): void
140134
}
141135
}
142136

143-
private function applyBasePath(DispatchContext $context): void
144-
{
145-
$basePath = $this->routeProvider->getBasePath();
146-
if ($basePath === '') {
147-
return;
148-
}
149-
150-
$context->setPath(
151-
preg_replace(
152-
'#^' . preg_quote($basePath) . '#',
153-
'',
154-
$context->path(),
155-
) ?? $context->path(),
156-
);
157-
}
158-
159137
/** @param array<int, mixed> $params */
160138
private function configureContext(
161139
DispatchContext $context,
162140
AbstractRoute $route,
163141
array $params = [],
164142
): DispatchContext {
165-
$context->route = $route;
166-
$context->params = $params;
143+
$context->configureRoute($route, $params);
167144

168145
return $context;
169146
}
@@ -176,7 +153,7 @@ private function getMatchedRoutesByPath(DispatchContext $context): SplObjectStor
176153

177154
foreach ($this->routeProvider->getRoutes() as $route) {
178155
$params = [];
179-
if (!$this->matchRoute($context, $route, $params)) {
156+
if (!$route->match($context, $params)) {
180157
continue;
181158
}
182159

@@ -265,21 +242,6 @@ private function hasExplicitOptionsRoute(SplObjectStorage $matchedByPath): bool
265242
return false;
266243
}
267244

268-
/** @param array<int, mixed> $params */
269-
private function matchRoute(
270-
DispatchContext $context,
271-
AbstractRoute $route,
272-
array &$params = [],
273-
): bool {
274-
if (!$route->match($context, $params)) {
275-
return false;
276-
}
277-
278-
$context->route = $route;
279-
280-
return true;
281-
}
282-
283245
/** @param SplObjectStorage<AbstractRoute, array<int, mixed>> $matchedByPath */
284246
private function routineMatch(
285247
DispatchContext $context,
@@ -296,7 +258,6 @@ private function routineMatch(
296258
/** @var array<int, mixed> $tempParams */
297259
$tempParams = $matchedByPath[$route];
298260
$context->clearResponseMeta();
299-
$context->route = $route;
300261
if ($this->routinePipeline->matches($context, $route, $tempParams)) {
301262
return $this->configureContext(
302263
$context,

0 commit comments

Comments
 (0)