diff --git a/.castor/docker/main/Dockerfile b/.castor/docker/main/Dockerfile index fc13196..d545458 100644 --- a/.castor/docker/main/Dockerfile +++ b/.castor/docker/main/Dockerfile @@ -24,6 +24,7 @@ RUN apk add --no-cache wget php8 \ php8-mbstring \ php8-openssl \ php8-pcntl \ + php8-posix \ php8-pecl-xdebug # Link PHP diff --git a/.castor/docs/README.md b/.castor/docs/README.md index 248d387..1a50c70 100644 --- a/.castor/docs/README.md +++ b/.castor/docs/README.md @@ -11,7 +11,7 @@ Creating a router is extremely simple: $router = Castor\Http\Router::create(); ``` -Castor router implements `Psr\Http\Server\RequestHandlerInterface` so you can use it to handle any PRS-7 Server Request. +Castor router implements `Psr\Http\Server\RequestHandlerInterface` so you can use it to handle any PSR-7 Server Request. ```php handle($aRequest); ``` -> NOTE: An empty router will throw a `Castor\Http\ProtocolError` when its `handle` method is called. +> NOTE: An empty router will throw a `Castor\Http\EmptyStackError` when its `handle` method is called. -You can add routes by calling `method` and `path` methods in the router instance and passing a handler. +You can add routes by calling methods named after http methods and passing a path and a handler. ```php method('GET')->path('/users')->handler($listUsersHandler); -$router->method('GET')->path('/users/:id')->handler($findUserHandler); -$router->method('POST')->path('/users')->handler($createUserHandler); -$router->method('DELETE')->path('/users/:id')->handler($deleteUserHandler); +$router->get('/users', $listUsersHandler); +$router->get('/users/:id', $findUserHandler); +$router->post('/users', $createUserHandler); +$router->delete('/users/:id', $deleteUserHandler); ``` As you can see, you can pass routing parameters using `:` notation when defining your route. -You can retrieve routing parameters using the `Castor\Http\Router\params` function and passing a Psr Server Request. +You can retrieve routing parameters by calling the `getAttribute` method on the `$request` and passing the param +name. ```php getAttribute('id'); // TODO: Do something with the id and return a response. } } @@ -60,13 +61,14 @@ class MyHandler implements PsrHandler ## Path Handlers As we have shown above, you can create a Route that responds to a method-path match with a specific handler. But you -can also create a Route that executes a handler upon matching a Path. Just call `path` without calling `method`. +can also create a Route that executes a handler upon matching a Path. Just call the `path` function in the router +class. ```php path('/users')->handler($aHandler); +$router->path('/users', $aHandler); ``` The `$aHandler` handler will be executed when the path matches `/users`. @@ -77,11 +79,11 @@ By using path matching, you can mount routers on routers and build a routing tre ```php $routerOne = Castor\Http\Router::create(); -$routerOne->method('GET')->handler($aHandler); -$routerOne->method('GET')->path('/:id')->handler($aHandler); +$routerOne->get('/', $aHandler); +$routerOne->get('/:id', $aHandler); $routerTwo = Castor\Http\Router::create(); -$routerTwo->path('/users')->handler($routerOne); +$routerTwo->path('/users', $routerOne); ``` Here we mount `$routerOne` into the `/users` path in `$routerTwo`, which causes all the `$routerOne` routes to match @@ -90,5 +92,5 @@ under `/users` path. For instance, the second route with id will match a `GET /u ## Error Handling Once you have built all of your routes, we recommend wrapping the router into a `Castor\Http\ErrorHandler`. You will -need a Psr Response Factory, and passing a logger is highly recommended. This will print debugging information as well -as normalizing http responses. \ No newline at end of file +need a Psr Response Factory. This is so the router can respond on errors and not simply throw +exceptions. \ No newline at end of file diff --git a/Makefile b/Makefile index 75508d2..c30a6a2 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ COMPOSE_FLAGS = --project-directory .castor/docker --env-file=.castor/docker/.env -COMPOSE_CMD = docker compose $(COMPOSE_FLAGS) +COMPOSE_CMD = docker-compose $(COMPOSE_FLAGS) build: $(COMPOSE_CMD) build diff --git a/src/DefaultFinalHandler.php b/src/DefaultFinalHandler.php index ac5d6a8..47c9c3f 100644 --- a/src/DefaultFinalHandler.php +++ b/src/DefaultFinalHandler.php @@ -27,16 +27,17 @@ final class DefaultFinalHandler implements PsrHandler { /** - * @throws ProtocolError + * @throws MethodNotAllowed + * @throws RouteNotFound */ public function handle(Request $request): Response { $message = sprintf('Cannot serve %s %s:', $request->getMethod(), $request->getUri()->getPath()); $allowedMethods = $request->getAttribute(ALLOWED_METHODS_ATTR, []); if ([] === $allowedMethods) { - throw new ProtocolError(404, $message.' Path not found'); + throw new RouteNotFound('Route not found'); } - throw new ProtocolError(405, $message.' Allowed methods are '.implode(', ', $allowedMethods)); + throw new MethodNotAllowed('Method not allowed', $allowedMethods); } } diff --git a/src/EmptyStackError.php b/src/EmptyStackError.php index bb3bf68..c732f30 100644 --- a/src/EmptyStackError.php +++ b/src/EmptyStackError.php @@ -16,8 +16,6 @@ namespace Castor\Http; -use Exception; - -class EmptyStackError extends Exception +class EmptyStackError extends RoutingError { } diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index 1642b6b..0f8143c 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -20,7 +20,6 @@ use Psr\Http\Message\ResponseInterface as PsrResponse; use Psr\Http\Message\ServerRequestInterface as PsrRequest; use Psr\Http\Server\RequestHandlerInterface as PsrHandler; -use Psr\Log\LoggerInterface as PsrLogger; use Throwable; /** @@ -32,78 +31,31 @@ final class ErrorHandler implements PsrHandler { private PsrHandler $next; private PsrResponseFactory $response; - private ?PsrLogger $logger; - private string $requestIdHeader; - private bool $logClientErrors; public function __construct( PsrHandler $next, - PsrResponseFactory $response, - PsrLogger $logger = null, - string $requestIdHeader = 'X-Request-Id', - bool $logClientErrors = false + PsrResponseFactory $response ) { $this->next = $next; $this->response = $response; - $this->logger = $logger; - $this->requestIdHeader = $requestIdHeader; - $this->logClientErrors = $logClientErrors; } public function handle(PsrRequest $request): PsrResponse { - $request = $this->identifyRequest($request); - try { return $this->next->handle($request); - } catch (Throwable $e) { - if (!$e instanceof ProtocolError) { - $e = new ProtocolError(500, 'Internal Server Error', $e); - } - $this->logError($request, $e); - - return $this->createErrorResponse($request, $e); + } catch (RouteNotFound $e) { + return $this->createErrorResponse($request, 404); + } catch (MethodNotAllowed $e) { + return $this->createErrorResponse($request, 405); + } catch (EmptyStackError | Throwable $e) { + return $this->createErrorResponse($request, 500); } } - private function logError(PsrRequest $request, ProtocolError $error): void + private function createErrorResponse(PsrRequest $request, int $status): PsrResponse { - if (null === $this->logger) { - return; - } - - $code = $error->getCode(); - - if ($code < 500 && !$this->logClientErrors) { - return; - } - - $uri = (string) $request->getUri(); - $method = $request->getMethod(); - $msg = sprintf('Error %s while trying to %s %s', $code, $method, $uri); - $id = $request->getHeaderLine($this->requestIdHeader); - - // We log the error in a error entry. - $this->logger->error($msg, [ - 'method' => $method, - 'code' => $code, - 'uri' => $uri, - 'request_id' => $id, - 'errors' => $error->toArray(), - ]); - - // We store the payload and headers received in a debug entry. - $this->logger->debug('Debugging information for request '.$id, [ - 'request_id' => $id, - 'payload' => base64_encode((string) $request->getBody()), - 'headers' => $request->getHeaders(), - ]); - } - - private function createErrorResponse(PsrRequest $request, ProtocolError $error): PsrResponse - { - $response = $this->response->createResponse($error->getCode()) - ->withHeader($this->requestIdHeader, $request->getHeaderLine($this->requestIdHeader)) + $response = $this->response->createResponse($status) ->withHeader('Content-Type', 'text/plain') ; $msg = sprintf('Could not %s %s', $request->getMethod(), $request->getUri()); @@ -111,19 +63,4 @@ private function createErrorResponse(PsrRequest $request, ProtocolError $error): return $response; } - - private function identifyRequest(PsrRequest $request): PsrRequest - { - $id = $request->getHeaderLine($this->requestIdHeader); - if ('' === $id) { - try { - $id = bin2hex(random_bytes(16)); - } catch (\Exception $e) { - throw new \RuntimeException('Could not generate a unique id for request', 0, $e); - } - $request = $request->withHeader($this->requestIdHeader, $id); - } - - return $request; - } } diff --git a/src/FlattenRouteParams.php b/src/FlattenRouteParams.php deleted file mode 100644 index 9f4f8bd..0000000 --- a/src/FlattenRouteParams.php +++ /dev/null @@ -1,50 +0,0 @@ -next = $next; - } - - public static function make(PsrHandler $next): PsrHandler - { - return new self($next); - } - - public function handle(PsrRequest $request): PsrResponse - { - $params = params($request); - foreach ($params as $key => $value) { - $request = $request->withAttribute($key, $value); - } - - return $this->next->handle($request); - } -} diff --git a/src/MethodNotAllowed.php b/src/MethodNotAllowed.php new file mode 100644 index 0000000..b046c93 --- /dev/null +++ b/src/MethodNotAllowed.php @@ -0,0 +1,35 @@ +allowedMethods = $allowedMethods; + } + + public function getAllowedMethods(): array + { + return $this->allowedMethods; + } +} diff --git a/src/ProtocolError.php b/src/ProtocolError.php deleted file mode 100644 index fb262d6..0000000 --- a/src/ProtocolError.php +++ /dev/null @@ -1,49 +0,0 @@ - get_class($error), - 'msg' => $error->getMessage(), - 'code' => $error->getCode(), - 'line' => $error->getLine(), - 'file' => $error->getFile(), - ]; - $error = $error->getPrevious(); - } - - return $errors; - } -} diff --git a/src/Route.php b/src/Route.php index 6096675..d67ff44 100644 --- a/src/Route.php +++ b/src/Route.php @@ -17,7 +17,6 @@ namespace Castor\Http; use const Castor\Http\Router\ALLOWED_METHODS_ATTR; -use const Castor\Http\Router\PARAMS_ATTR; use const Castor\Http\Router\PATH_ATTR; use MNC\PathToRegExpPHP\NoMatchException; use MNC\PathToRegExpPHP\PathRegExpFactory; @@ -31,62 +30,25 @@ */ class Route implements PsrMiddleware { - private Router $router; + private PsrHandler $handler; /** * @var string[] */ private array $methods; private string $pattern; - private ?PsrHandler $handler; /** * Route constructor. */ - public function __construct(Router $router, array $methods = [], string $pattern = '/', PsrHandler $handler = null) + public function __construct(PsrHandler $handler, array $methods = [], string $pattern = '/') { - $this->router = $router; - $this->methods = $methods; - $this->pattern = $pattern; $this->handler = $handler; - } - - public function method(string ...$methods): Route - { $this->methods = $methods; - - return $this; - } - - public function path(string $pattern): Route - { $this->pattern = $pattern; - - return $this; } - public function handler(PsrHandler $handler): Route - { - $this->handler = $handler; - - return $this; - } - - public function router(): Router - { - $this->handler = $this->router->new(); - - return $this->handler; - } - - /** - * @throws ProtocolError - */ public function process(PsrRequest $request, PsrHandler $handler): PsrResponse { - if (null === $this->handler) { - throw new ProtocolError(501, 'Handler has not been defined for route'); - } - $hasMethods = [] !== $this->methods; $methodMatches = in_array($request->getMethod(), $this->methods, true); @@ -145,12 +107,11 @@ protected function matchPath(string $path, bool $full): callable $request = $request->withAttribute(PATH_ATTR, $path); // Store the attributes in the request - $params = $request->getAttribute(PARAMS_ATTR) ?? []; foreach ($result->getValues() as $attr => $value) { - $params[$attr] = $value; + $request = $request->withAttribute($attr, $value); } - return $request->withAttribute(PARAMS_ATTR, $params); + return $request; }; } } diff --git a/src/RouteNotFound.php b/src/RouteNotFound.php new file mode 100644 index 0000000..6cbf58b --- /dev/null +++ b/src/RouteNotFound.php @@ -0,0 +1,21 @@ +fallbackHandler, ...$this->middleware); - } catch (EmptyStackError $e) { - throw new ProtocolError(500, 'Router does not have any handlers that can handle the request', $e); - } + $handler = Frame::stack($this->fallbackHandler, ...$this->middleware); return $handler->handle($request); } - public function path(string $path): Route + /** + * Registers a $handler to run when matching $path and GET method. + */ + public function get(string $path, PSrHandler $handler): Route + { + return $this->route([METHOD_GET, METHOD_HEAD], $path, $handler); + } + + /** + * Registers a $handler to run when matching $path and POST method. + */ + public function post(string $path, PSrHandler $handler): Route { - $route = new Route($this, [], $path); + return $this->route([METHOD_POST], $path, $handler); + } + + /** + * Registers a $handler to run when matching $path and PUT method. + */ + public function put(string $path, PSrHandler $handler): Route + { + return $this->route([METHOD_PUT], $path, $handler); + } + + /** + * Registers a $handler to run when matching $path and PATCH method. + */ + public function patch(string $path, PSrHandler $handler): Route + { + return $this->route([METHOD_PATCH], $path, $handler); + } + + /** + * Registers a $handler to run when matching $path and DELETE method. + */ + public function delete(string $path, PSrHandler $handler): Route + { + return $this->route([METHOD_DELETE], $path, $handler); + } + + /** + * Registers a $handler to run when matching $path and $methods. + */ + public function route(array $methods, string $path, PSrHandler $handler): Route + { + $route = new Route($handler, $methods, $path); $this->use($route); return $route; } - public function method(string ...$methods): Route + /** + * Registers a $handler to run when matching $path. + */ + public function path(string $path, PSrHandler $handler): Route { - $route = new Route($this, $methods); + $route = new Route($handler, [], $path); $this->use($route); return $route; diff --git a/src/RoutingError.php b/src/RoutingError.php new file mode 100644 index 0000000..aebc051 --- /dev/null +++ b/src/RoutingError.php @@ -0,0 +1,23 @@ +getAttribute(PARAMS_ATTR) ?? []; -} namespace Castor\Http; diff --git a/tests/RouteTest.php b/tests/RouteTest.php index e492214..dd73e3e 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -16,7 +16,6 @@ namespace Castor\Http; -use const Castor\Http\Router\PARAMS_ATTR; use const Castor\Http\Router\PATH_ATTR; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -30,27 +29,14 @@ */ class RouteTest extends TestCase { - public function testItThrowsNotImplemented(): void - { - $router = $this->createStub(Router::class); - $request = $this->createStub(ServerRequestInterface::class); - $handler = $this->createStub(RequestHandlerInterface::class); - - $route = new Route($router); - $this->expectException(ProtocolError::class); - $route->process($request, $handler); - } - public function testItProcessMethodsOnly(): void { - $router = $this->createStub(Router::class); $response = $this->createStub(ResponseInterface::class); $request = $this->createMock(ServerRequestInterface::class); $uri = $this->createMock(UriInterface::class); $routeHandler = $this->createMock(RequestHandlerInterface::class); $nextHandler = $this->createMock(RequestHandlerInterface::class); - $route = new Route($router, ['PUT']); - $route->handler($routeHandler); + $route = new Route($routeHandler, ['PUT'], '/'); $request->expects($this->once()) ->method('getMethod') @@ -64,14 +50,14 @@ public function testItProcessMethodsOnly(): void ->method('getPath') ->willReturn('/') ; - $request->expects($this->exactly(2)) + $request->expects($this->once()) ->method('getAttribute') - ->withConsecutive([PATH_ATTR], [PARAMS_ATTR]) + ->with(PATH_ATTR) ->willReturn(null) ; - $request->expects($this->exactly(2)) + $request->expects($this->once()) ->method('withAttribute') - ->withConsecutive([PATH_ATTR, '/'], [PARAMS_ATTR, []]) + ->with(PATH_ATTR, '/') ->willReturn($request) ; $routeHandler->expects($this->once()) @@ -85,14 +71,12 @@ public function testItProcessMethodsOnly(): void public function testItProcessPathOnly(): void { - $router = $this->createStub(Router::class); $response = $this->createStub(ResponseInterface::class); $request = $this->createMock(ServerRequestInterface::class); $uri = $this->createMock(UriInterface::class); $routeHandler = $this->createMock(RequestHandlerInterface::class); $nextHandler = $this->createMock(RequestHandlerInterface::class); - $route = new Route($router); - $route->path('/users')->handler($routeHandler); + $route = new Route($routeHandler, [], '/users'); $request->expects($this->once()) ->method('getMethod') @@ -106,14 +90,14 @@ public function testItProcessPathOnly(): void ->method('getPath') ->willReturn('/users/123') ; - $request->expects($this->exactly(2)) + $request->expects($this->once()) ->method('getAttribute') - ->withConsecutive([PATH_ATTR], [PARAMS_ATTR]) + ->with(PATH_ATTR) ->willReturn(null) ; - $request->expects($this->exactly(2)) + $request->expects($this->once()) ->method('withAttribute') - ->withConsecutive([PATH_ATTR, '/123'], [PARAMS_ATTR, []]) + ->with(PATH_ATTR, '/123') ->willReturn($request) ; $nextHandler->expects($this->never()) @@ -130,14 +114,12 @@ public function testItProcessPathOnly(): void public function testItProcessMethodAndPath(): void { - $router = $this->createStub(Router::class); $response = $this->createStub(ResponseInterface::class); $request = $this->createMock(ServerRequestInterface::class); $uri = $this->createMock(UriInterface::class); $routeHandler = $this->createMock(RequestHandlerInterface::class); $nextHandler = $this->createMock(RequestHandlerInterface::class); - $route = new Route($router); - $route->method('GET')->path('/users')->handler($routeHandler); + $route = new Route($routeHandler, ['GET'], '/users'); $request->expects($this->once()) ->method('getMethod') @@ -151,14 +133,14 @@ public function testItProcessMethodAndPath(): void ->method('getPath') ->willReturn('/users') ; - $request->expects($this->exactly(2)) + $request->expects($this->once()) ->method('getAttribute') - ->withConsecutive([PATH_ATTR], [PARAMS_ATTR]) + ->with(PATH_ATTR) ->willReturn(null) ; - $request->expects($this->exactly(2)) + $request->expects($this->once()) ->method('withAttribute') - ->withConsecutive([PATH_ATTR, '/'], [PARAMS_ATTR, []]) + ->with(PATH_ATTR, '/') ->willReturn($request) ; $nextHandler->expects($this->never()) diff --git a/tests/RouterTest.php b/tests/RouterTest.php index cae4158..71293a4 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -16,7 +16,6 @@ namespace Castor\Http; -use function Castor\Http\Router\params; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -30,21 +29,21 @@ public function testItThrowsEmptyStack(): void { $request = $this->createStub(ServerRequestInterface::class); $router = Router::create(); - $this->expectException(ProtocolError::class); + $this->expectException(EmptyStackError::class); $router->handle($request); } public function testMatchesGetRequest(): void { $handler = function (ServerRequestInterface $req): ResponseInterface { - $id = params($req)['id'] ?? null; + $id = $req->getAttribute('id'); self::assertSame('1234', $id); return $this->createResponse('OK'); }; $router = Router::create(); - $router->method('GET')->path('/users/:id')->handler($this->functionHandler($handler)); + $router->get('/users/:id', $this->functionHandler($handler)); $response = $router->handle($this->createRequest('GET', 'https://example.com/users/1234')); self::assertSame('OK', (string) $response->getBody());