Skip to content
Merged
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
47 changes: 41 additions & 6 deletions src/DispatchContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Respect\Rest\Routines\Routinable;
use Throwable;

use function is_a;
use function rawurldecode;
use function rtrim;
use function set_error_handler;
Expand Down Expand Up @@ -49,6 +50,9 @@ final class DispatchContext

private string $effectivePath = '';

/** @var array<int, AbstractRoute> */
private array $sideRoutes = [];

public function __construct(
public ServerRequestInterface $request,
public ResponseFactoryInterface&StreamFactoryInterface $factory,
Expand Down Expand Up @@ -121,6 +125,11 @@ public function response(): ResponseInterface|null
{
if (!$this->route instanceof AbstractRoute) {
if ($this->responseDraft !== null) {
$statusResponse = $this->forwardToStatusRoute($this->responseDraft);
if ($statusResponse !== null) {
return $statusResponse;
}

return $this->finalizeResponse($this->responseDraft);
}

Expand Down Expand Up @@ -210,6 +219,12 @@ public function setRoutinePipeline(RoutinePipeline $routinePipeline): void
$this->routinePipeline = $routinePipeline;
}

/** @param array<int, AbstractRoute> $sideRoutes */
public function setSideRoutes(array $sideRoutes): void
{
$this->sideRoutes = $sideRoutes;
}

public function setResponder(Responder $responder): void
{
$this->responder = $responder;
Expand Down Expand Up @@ -260,12 +275,7 @@ protected function catchExceptions(Throwable $e, AbstractRoute $route): Response
continue;
}

$exceptionClass = $e::class;
if (
$exceptionClass === $sideRoute->class
|| $sideRoute->class === 'Exception'
|| $sideRoute->class === '\Exception'
) {
if (is_a($e, $sideRoute->class)) {
$sideRoute->exception = $e;

return $this->forward($sideRoute);
Expand All @@ -275,6 +285,31 @@ protected function catchExceptions(Throwable $e, AbstractRoute $route): Response
return null;
}

protected function forwardToStatusRoute(ResponseInterface $preparedResponse): ResponseInterface|null
{
$statusCode = $preparedResponse->getStatusCode();

foreach ($this->sideRoutes as $sideRoute) {
if (
$sideRoute instanceof Routes\Status
&& ($sideRoute->statusCode === $statusCode || $sideRoute->statusCode === null)
) {
$this->hasStatusOverride = true;

// Run routine negotiation (e.g. Accept) before forwarding,
// since the normal route-selection phase was skipped
$this->routinePipeline()->matches($this, $sideRoute, $this->params);

$result = $this->forward($sideRoute);

// Preserve the original status code on the forwarded response
return $result?->withStatus($statusCode);
}
}

return null;
}

/** @param array<int, mixed> $params */
protected function extractRouteParam(
ReflectionFunctionAbstract $callback,
Expand Down
22 changes: 12 additions & 10 deletions src/DispatchEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use Psr\Http\Server\RequestHandlerInterface;
use Respect\Rest\Routes\AbstractRoute;
use SplObjectStorage;
use Throwable;

use function array_filter;
use function array_keys;
Expand Down Expand Up @@ -49,11 +48,7 @@ public function dispatch(ServerRequestInterface $serverRequest): DispatchContext

public function handle(ServerRequestInterface $request): ResponseInterface
{
try {
$response = $this->dispatch($request)->response();
} catch (Throwable) {
return $this->factory->createResponse(500);
}
$response = $this->dispatch($request)->response();

return $response ?? $this->factory->createResponse(500);
}
Expand All @@ -65,6 +60,7 @@ public function dispatchContext(DispatchContext $context): DispatchContext
}

$context->setRoutinePipeline($this->routinePipeline);
$context->setSideRoutes($this->routeProvider->getSideRoutes());

if (!$this->isRoutelessDispatch($context) && $context->route === null) {
$this->routeDispatch($context);
Expand Down Expand Up @@ -289,9 +285,9 @@ private function routineMatch(
DispatchContext $context,
SplObjectStorage $matchedByPath,
): DispatchContext|bool|null {
$badRequest = false;

foreach ([0, 1, 2] as $rank) {
$rankMatched = false;

foreach ($matchedByPath as $route) {
if ($this->getMethodMatchRank($context, $route) !== $rank) {
continue;
Expand All @@ -309,11 +305,17 @@ private function routineMatch(
);
}

$badRequest = true;
$rankMatched = true;
}

// If a route at this rank matched the method but failed routines,
// don't fall through to lower-priority ranks
if ($rankMatched) {
return false;
}
}

return $badRequest ? false : null;
return null;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/RouteProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ interface RouteProvider
/** @return array<int, AbstractRoute> */
public function getRoutes(): array;

/** @return array<int, AbstractRoute> */
public function getSideRoutes(): array;

public function getBasePath(): string;
}
14 changes: 14 additions & 0 deletions src/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ public function errorRoute(callable $callback): Routes\Error
return $route;
}

public function statusRoute(int|null $statusCode, callable $callback): Routes\Status
{
$route = new Routes\Status($statusCode, $callback);
$this->appendSideRoute($route);

return $route;
}

/** @return array<int, Routes\AbstractRoute> */
public function getSideRoutes(): array
{
return $this->sideRoutes;
}

public function factoryRoute(string $method, string $path, string $className, callable $factory): Routes\Factory
{
$route = new Routes\Factory($method, $path, $className, $factory);
Expand Down
16 changes: 16 additions & 0 deletions src/Routes/Status.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Respect\Rest\Routes;

final class Status extends Callback
{
/** @var callable */
public $callback;

public function __construct(public readonly int|null $statusCode, callable $callback)
{
parent::__construct('ANY', '^$', $callback);
}
}
8 changes: 4 additions & 4 deletions tests/DispatchEngineTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,17 @@ public function testHandleReturnsPsr7Response(): void
self::assertSame('world', (string) $response->getBody());
}

public function testHandleReturns500OnException(): void
public function testHandlePropagatesUnhandledExceptions(): void
{
$engine = $this->engine([
new Callback('GET', '/boom', static function (): never {
throw new RuntimeException('fail');
}),
]);

$response = $engine->handle(new ServerRequest('GET', '/boom'));

self::assertSame(500, $response->getStatusCode());
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('fail');
$engine->handle(new ServerRequest('GET', '/boom'));
}

public function testOnContextReadyCallbackIsInvoked(): void
Expand Down
8 changes: 4 additions & 4 deletions tests/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1843,16 +1843,16 @@ public function testDispatchEngineHandleReturnsSameResponseAsDispatch(): void
self::assertSame((string) $dispatched->getBody(), (string) $handled->getBody());
}

public function testDispatchEngineHandleReturns500ForUncaughtExceptions(): void
public function testDispatchEngineHandlePropagatesUncaughtExceptions(): void
{
$router = self::newRouter();
$router->get('/', static function (): void {
throw new InvalidArgumentException('boom');
});

$response = $router->dispatchEngine()->handle(new ServerRequest('GET', '/'));

self::assertSame(500, $response->getStatusCode());
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('boom');
$router->dispatchEngine()->handle(new ServerRequest('GET', '/'));
}

public function testDispatchEngineHandlePreservesExceptionRoutes(): void
Expand Down
41 changes: 41 additions & 0 deletions tests/Routes/ExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\ServerRequest;
use PDOException;
use PHPUnit\Framework\TestCase;
use Respect\Rest\Router;
use RuntimeException;
use Throwable;

/** @covers Respect\Rest\Routes\Exception */
final class ExceptionTest extends TestCase
Expand Down Expand Up @@ -48,4 +50,43 @@ public function testMagicConstuctorCanCreateRoutesToExceptions(): void
'An exception should be caught by the router and forwarded',
);
}

public function testExceptionRouteCatchesSubclassViaInheritance(): void
{
$router = new Router('', new Psr17Factory());
$router->exceptionRoute('RuntimeException', static fn($e) => 'caught: ' . $e->getMessage());
$router->get('/', static function (): void {
throw new PDOException('db error');
});

$resp = $router->dispatch(new ServerRequest('GET', '/'))->response();
self::assertNotNull($resp);
self::assertSame('caught: db error', (string) $resp->getBody());
}

public function testThrowableExceptionRouteCatchesAll(): void
{
$router = new Router('', new Psr17Factory());
$router->exceptionRoute('Throwable', static fn(Throwable $e) => 'caught: ' . $e::class);
$router->get('/', static function (): void {
throw new RuntimeException('test');
});

$resp = $router->dispatch(new ServerRequest('GET', '/'))->response();
self::assertNotNull($resp);
self::assertSame('caught: RuntimeException', (string) $resp->getBody());
}

public function testExceptionRouteWorksViaHandle(): void
{
$router = new Router('', new Psr17Factory());
$router->exceptionRoute('Throwable', static fn(Throwable $e) => 'handled: ' . $e->getMessage());
$router->get('/', static function (): void {
throw new RuntimeException('boom');
});

$response = $router->handle(new ServerRequest('GET', '/'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('handled: boom', (string) $response->getBody());
}
}
Loading