From 520709cd7efc1cf1ff01559f78430267568b4b64 Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Sat, 29 Aug 2026 16:47:15 +0200 Subject: [PATCH] application-builder --- README.md | 49 ++- composer.json | 2 +- doc/Facade/AbstractCollector.md | 388 ++++++++++++++++ doc/Facade/ApplicationBuilder.md | 80 ++++ doc/Facade/GroupCollector.md | 43 ++ doc/Middleware/UrlGeneratorMiddleware.md | 38 ++ src/Facade/AbstractCollector.php | 313 +++++++++++++ src/Facade/ApplicationBuilder.php | 128 ++++++ src/Facade/GroupCollector.php | 32 ++ src/Middleware/UrlGeneratorMiddleware.php | 23 + tests/Integration/ApplicationBuilderTest.php | 236 ++++++++++ tests/Integration/DocumentationTest.php | 2 +- tests/Unit/Facade/ApplicationBuilderTest.php | 414 ++++++++++++++++++ tests/Unit/Facade/GroupCollectorTest.php | 172 ++++++++ .../Middleware/UrlGeneratorMiddlewareTest.php | 48 ++ 15 files changed, 1965 insertions(+), 3 deletions(-) create mode 100644 doc/Facade/AbstractCollector.md create mode 100644 doc/Facade/ApplicationBuilder.md create mode 100644 doc/Facade/GroupCollector.md create mode 100644 doc/Middleware/UrlGeneratorMiddleware.md create mode 100644 src/Facade/AbstractCollector.php create mode 100644 src/Facade/ApplicationBuilder.php create mode 100644 src/Facade/GroupCollector.php create mode 100644 src/Middleware/UrlGeneratorMiddleware.php create mode 100644 tests/Integration/ApplicationBuilderTest.php create mode 100644 tests/Unit/Facade/ApplicationBuilderTest.php create mode 100644 tests/Unit/Facade/GroupCollectorTest.php create mode 100644 tests/Unit/Middleware/UrlGeneratorMiddlewareTest.php diff --git a/README.md b/README.md index 2814eee..ce4a20b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Any Router which implements `Chubbyphp\Framework\Router\RouteMatcherInterface` c Through [Composer](http://getcomposer.org) as [chubbyphp/chubbyphp-framework][60]. ```bash -composer require chubbyphp/chubbyphp-framework "^6.0.2" \ +composer require chubbyphp/chubbyphp-framework "^6.1" \ chubbyphp/chubbyphp-framework-router-fastroute "^2.3.3" \ slim/psr7 "^1.8" ``` @@ -115,10 +115,52 @@ $app = new Application([ $app->emit($app->handle((new ServerRequestFactory())->createFromGlobals())); ``` +Or with the [ApplicationBuilder][67] facade, which wires the very same middleware pipe: + +```php + new RouteMatcher($routes); + +$app = ApplicationBuilder::create($responseFactory, $createRouteMatcher, debug: true) + ->get('/hello/{name:[a-z]+}', 'hello', new CallbackRequestHandler( + static function (ServerRequestInterface $request) use ($responseFactory) { + $response = $responseFactory->createResponse(); + $response->getBody()->write(sprintf('Hello, %s', $request->getAttribute('name'))); + + return $response; + } + )) + ->build(); + +$app->emit($app->handle((new ServerRequestFactory())->createFromGlobals())); +``` + ### Emitter * [Emitter][65] +### Facade + + * [AbstractCollector][66] + * [ApplicationBuilder][67] + * [GroupCollector][68] + ### Middleware * [CallbackMiddleware][70] @@ -128,6 +170,7 @@ $app->emit($app->handle((new ServerRequestFactory())->createFromGlobals())); * [RouteMatcherMiddleware][74] * [SlimCallbackMiddleware][75] * [SlimLazyMiddleware][76] + * [UrlGeneratorMiddleware][77] ### RequestHandler @@ -205,6 +248,9 @@ $app->emit($app->handle((new ServerRequestFactory())->createFromGlobals())); [60]: https://packagist.org/packages/chubbyphp/chubbyphp-framework [65]: doc/Emitter/Emitter.md +[66]: doc/Facade/AbstractCollector.md +[67]: doc/Facade/ApplicationBuilder.md +[68]: doc/Facade/GroupCollector.md [70]: doc/Middleware/CallbackMiddleware.md [71]: doc/Middleware/ExceptionMiddleware.md @@ -213,6 +259,7 @@ $app->emit($app->handle((new ServerRequestFactory())->createFromGlobals())); [74]: doc/Middleware/RouteMatcherMiddleware.md [75]: doc/Middleware/SlimCallbackMiddleware.md [76]: doc/Middleware/SlimLazyMiddleware.md +[77]: doc/Middleware/UrlGeneratorMiddleware.md [80]: doc/RequestHandler/CallbackRequestHandler.md [81]: doc/RequestHandler/LazyRequestHandler.md diff --git a/composer.json b/composer.json index e3475ae..08f589f 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ }, "extra": { "branch-alias": { - "dev-master": "6.0-dev" + "dev-master": "6.1-dev" } }, "scripts": { diff --git a/doc/Facade/AbstractCollector.md b/doc/Facade/AbstractCollector.md new file mode 100644 index 0000000..055b49b --- /dev/null +++ b/doc/Facade/AbstractCollector.md @@ -0,0 +1,388 @@ +# AbstractCollector + +Base class of `ApplicationBuilder` and `GroupCollector`, the examples below use `ApplicationBuilder`. + +## Methods + +### route + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->route( + 'TRACE', + '/{id}', + 'trace', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### delete + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->delete( + '/{id}', + 'delete', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### get + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->get( + '/{id}', + 'get', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### head + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->head( + '/{id}', + 'head', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### options + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->options( + '/{id}', + 'options', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### patch + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->patch( + '/{id}', + 'patch', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### post + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->post( + '/{id}', + 'post', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### put + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->put( + '/{id}', + 'put', + [$middleware], + $handler, + ['requirements' => ['id' => '\d+']] +); +``` + +### group + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$applicationBuilder = $applicationBuilder->group( + '/{id}', + [$middleware], + static fn (GroupCollector $group) => $group + ->get('', 'read', $handler) + ->put('', 'update', [$middleware], $handler) + ->group('/sub', static fn (GroupCollector $sub) => $sub->get('', 'sub_read', $handler)), + ['requirements' => ['id' => '\d+']] +); +``` + diff --git a/doc/Facade/ApplicationBuilder.md b/doc/Facade/ApplicationBuilder.md new file mode 100644 index 0000000..7089d00 --- /dev/null +++ b/doc/Facade/ApplicationBuilder.md @@ -0,0 +1,80 @@ +# ApplicationBuilder + +Extends [AbstractCollector](AbstractCollector.md), see there for the inherited `route` / `delete` / `get` / `head` / `options` / `patch` / `post` / `put` / `group` methods. + +## Methods + +### create + +```php +handle($request); + } +}; + +$createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); +$createUrlGenerator = static fn (RoutesByNameInterface $routes) => new UrlGenerator($routes); + +/** @var LoggerInterface $logger */ +$logger = new Logger(); + +$applicationBuilder = ApplicationBuilder::create( + new ResponseFactory(), + $createRouteMatcher, + [$middleware], + $createUrlGenerator, + true, + $logger +); +``` + +### build + +```php + new RouteMatcher($routes); + +$applicationBuilder = ApplicationBuilder::create(new ResponseFactory(), $createRouteMatcher); + +$app = $applicationBuilder + ->get('/ping', 'ping', $handler) + ->build(); + +$app->emit($app->handle($request)); +``` diff --git a/doc/Facade/GroupCollector.md b/doc/Facade/GroupCollector.md new file mode 100644 index 0000000..dac5e7c --- /dev/null +++ b/doc/Facade/GroupCollector.md @@ -0,0 +1,43 @@ +# GroupCollector + +Extends [AbstractCollector](AbstractCollector.md), see there for the inherited `route` / `delete` / `get` / `head` / `options` / `patch` / `post` / `put` / `group` methods. + +## Methods + +### create + +```php +get('/{id}', 'read', $handler) + ->group('/sub', static fn (GroupCollector $sub) => $sub->get('', 'sub_read', $handler)); + +/** @var list $children */ +$children = $groupCollector->getChildren(); +``` diff --git a/doc/Middleware/UrlGeneratorMiddleware.md b/doc/Middleware/UrlGeneratorMiddleware.md new file mode 100644 index 0000000..0a3e502 --- /dev/null +++ b/doc/Middleware/UrlGeneratorMiddleware.md @@ -0,0 +1,38 @@ +# UrlGeneratorMiddleware + +Adds the given `UrlGeneratorInterface` as request attribute `urlGenerator` (`UrlGeneratorMiddleware::ATTRIBUTE`), +so it is available within the following middlewares and request handlers. + +## Methods + +### process + +```php +getAttribute(UrlGeneratorMiddleware::ATTRIBUTE); + + $urlGenerator->generatePath('pet_read', ['id' => '1']); + + return new Response(); + } +}; + +$urlGeneratorMiddleware = new UrlGeneratorMiddleware(new UrlGenerator()); + +$response = $urlGeneratorMiddleware->process($request, $handler); +``` diff --git a/src/Facade/AbstractCollector.php b/src/Facade/AbstractCollector.php new file mode 100644 index 0000000..d37c223 --- /dev/null +++ b/src/Facade/AbstractCollector.php @@ -0,0 +1,313 @@ + $children + */ + protected function __construct(protected readonly array $children) {} + + /** + * Overloads (mirroring the typescript facade), middlewares are optional and given directly before the + * request handler they wrap: + * - route($method, $path, $name, $requestHandler, $pathOptions = []) + * - route($method, $path, $name, $middlewares, $requestHandler, $pathOptions = []). + * + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function route( + string $method, + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + [$middlewares, $requestHandler, $resolvedPathOptions] = self::resolveRouteArguments( + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + + return $this->withChildren([ + ...$this->children, + Route::create($method, $path, $name, $requestHandler, $middlewares, $resolvedPathOptions), + ]); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function delete( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'DELETE', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function get( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'GET', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function head( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'HEAD', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function options( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'OPTIONS', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function patch( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'PATCH', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function post( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'POST', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + */ + final public function put( + string $path, + string $name, + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions = null, + array $pathOptions = [] + ): static { + return $this->route( + 'PUT', + $path, + $name, + $middlewaresOrRequestHandler, + $requestHandlerOrPathOptions, + $pathOptions + ); + } + + /** + * Overloads (mirroring the typescript facade), middlewares are optional and given directly before the + * configure callback they wrap: + * - group($path, $configure, $pathOptions = []) + * - group($path, $middlewares, $configure, $pathOptions = []). + * + * @param array|callable(GroupCollector): GroupCollector $middlewaresOrConfigure + * @param null|array|callable(GroupCollector): GroupCollector $configureOrPathOptions + * @param array $pathOptions + */ + final public function group( + string $path, + array|callable $middlewaresOrConfigure, + array|callable|null $configureOrPathOptions = null, + array $pathOptions = [] + ): static { + [$middlewares, $configure, $resolvedPathOptions] = self::resolveGroupArguments( + $middlewaresOrConfigure, + $configureOrPathOptions, + $pathOptions + ); + + return $this->withChildren([ + ...$this->children, + Group::create( + $path, + $configure(GroupCollector::create())->getChildren(), + $middlewares, + $resolvedPathOptions + ), + ]); + } + + /** + * @param list $children + */ + abstract protected function withChildren(array $children): static; + + /** + * @param array|callable(GroupCollector): GroupCollector $middlewaresOrConfigure + * @param null|array|callable(GroupCollector): GroupCollector $configureOrPathOptions + * @param array $pathOptions + * + * @return array{ + * 0: array, + * 1: callable(GroupCollector): GroupCollector, + * 2: array + * } + */ + private static function resolveGroupArguments( + array|callable $middlewaresOrConfigure, + array|callable|null $configureOrPathOptions, + array $pathOptions + ): array { + if (\is_callable($middlewaresOrConfigure)) { + if (\is_callable($configureOrPathOptions)) { + throw new \InvalidArgumentException( + 'group(): with configure as second parameter, the third one must be pathOptions (array)' + ); + } + + $resolvedPathOptions = $configureOrPathOptions ?? []; + + return [[], $middlewaresOrConfigure, $resolvedPathOptions]; + } + + if (!\is_callable($configureOrPathOptions)) { + throw new \InvalidArgumentException( + 'group(): with middlewares as second parameter, the third one must be the configure callback' + ); + } + + return [$middlewaresOrConfigure, $configureOrPathOptions, $pathOptions]; + } + + /** + * @param array|RequestHandlerInterface $middlewaresOrRequestHandler + * @param null|array|RequestHandlerInterface $requestHandlerOrPathOptions + * @param array $pathOptions + * + * @return array{0: array, 1: RequestHandlerInterface, 2: array} + */ + private static function resolveRouteArguments( + array|RequestHandlerInterface $middlewaresOrRequestHandler, + array|RequestHandlerInterface|null $requestHandlerOrPathOptions, + array $pathOptions + ): array { + if ($middlewaresOrRequestHandler instanceof RequestHandlerInterface) { + if (!\is_array($requestHandlerOrPathOptions) && null !== $requestHandlerOrPathOptions) { + throw new \InvalidArgumentException( + 'route(): with the request handler as content parameter, the next one must be pathOptions (array)' + ); + } + + return [[], $middlewaresOrRequestHandler, $requestHandlerOrPathOptions ?? []]; + } + + if (!$requestHandlerOrPathOptions instanceof RequestHandlerInterface) { + throw new \InvalidArgumentException( + 'route(): with middlewares as first content parameter, the next one must be the request handler' + ); + } + + return [$middlewaresOrRequestHandler, $requestHandlerOrPathOptions, $pathOptions]; + } +} diff --git a/src/Facade/ApplicationBuilder.php b/src/Facade/ApplicationBuilder.php new file mode 100644 index 0000000..16c9df7 --- /dev/null +++ b/src/Facade/ApplicationBuilder.php @@ -0,0 +1,128 @@ + new RouteMatcher($routes)` from + * `chubbyphp/chubbyphp-framework-router-fastroute`. + * + * The application builder is immutable: every route / group call returns a new application builder, so use + * the return value (chaining or reassignment). Middlewares are given as an optional parameter directly + * before the element content they wrap (the request handler / the group configure callback), and can be + * omitted entirely if there are none. Route names are given as the required second parameter. Routes and + * groups accept pathOptions as an optional last parameter, group pathOptions are merged into their children. + * Beside the seven method shortcuts (delete / get / head / options / patch / post / put) there is a generic + * `route` accepting any method as its first parameter: `->route('TRACE', '/trace', 'trace', $handler)`. + * + * With the createUrlGenerator option (for example + * `static fn (RoutesByNameInterface $routes) => new UrlGenerator($routes)`) an `urlGenerator` request + * attribute becomes available within middlewares and request handlers: + * `$request->getAttribute(UrlGeneratorMiddleware::ATTRIBUTE)->generatePath('pet_read', ['id' => '1'])`. + * + * ```php + * $createRouteMatcher = static fn (RoutesByNameInterface $routes) => new RouteMatcher($routes); + * + * $app = ApplicationBuilder::create($responseFactory, $createRouteMatcher, [$corsMiddleware]) + * ->get('/ping', 'ping', $pingHandler) + * ->get('/openapi', 'openapi', $openApiHandler) + * ->group( + * '/api/pets', + * [$acceptNegotiationMiddleware, $apiErrorMiddleware], + * static fn (GroupCollector $pets) => $pets + * ->get('', 'pet_list', $petListHandler) + * ->post('', 'pet_create', [$contentTypeNegotiationMiddleware], $petCreateHandler) + * ->get('/{id}', 'pet_read', $petReadHandler) + * ->put('/{id}', 'pet_update', [$contentTypeNegotiationMiddleware], $petUpdateHandler) + * ->delete('/{id}', 'pet_delete', $petDeleteHandler), + * ) + * ->build(); + * ``` + */ +final class ApplicationBuilder extends AbstractCollector +{ + /** + * @param callable(RoutesByNameInterface): RouteMatcherInterface $createRouteMatcher + * @param array $middlewares + * @param null|callable(RoutesByNameInterface): UrlGeneratorInterface $createUrlGenerator + * @param list $children + */ + private function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly mixed $createRouteMatcher, + private readonly array $middlewares, + private readonly mixed $createUrlGenerator, + private readonly bool $debug, + private readonly ?LoggerInterface $logger, + array $children + ) { + parent::__construct($children); + } + + /** + * @param callable(RoutesByNameInterface): RouteMatcherInterface $createRouteMatcher + * @param array $middlewares + * @param null|callable(RoutesByNameInterface): UrlGeneratorInterface $createUrlGenerator + */ + public static function create( + ResponseFactoryInterface $responseFactory, + callable $createRouteMatcher, + array $middlewares = [], + ?callable $createUrlGenerator = null, + bool $debug = false, + ?LoggerInterface $logger = null + ): self { + return new self($responseFactory, $createRouteMatcher, $middlewares, $createUrlGenerator, $debug, $logger, []); + } + + public function build(): Application + { + $routesByName = new RoutesByName(Group::create('', $this->children)->getRoutes()); + + $urlGenerator = null !== $this->createUrlGenerator ? ($this->createUrlGenerator)($routesByName) : null; + + return new Application([ + new ExceptionMiddleware($this->responseFactory, $this->debug, $this->logger), + ...(null !== $urlGenerator ? [new UrlGeneratorMiddleware($urlGenerator)] : []), + ...$this->middlewares, + new RouteMatcherMiddleware(($this->createRouteMatcher)($routesByName)), + ]); + } + + /** + * @param list $children + */ + protected function withChildren(array $children): static + { + return new self( + $this->responseFactory, + $this->createRouteMatcher, + $this->middlewares, + $this->createUrlGenerator, + $this->debug, + $this->logger, + $children + ); + } +} diff --git a/src/Facade/GroupCollector.php b/src/Facade/GroupCollector.php new file mode 100644 index 0000000..09834b6 --- /dev/null +++ b/src/Facade/GroupCollector.php @@ -0,0 +1,32 @@ + + */ + public function getChildren(): array + { + return $this->children; + } + + /** + * @param list $children + */ + protected function withChildren(array $children): static + { + return new self($children); + } +} diff --git a/src/Middleware/UrlGeneratorMiddleware.php b/src/Middleware/UrlGeneratorMiddleware.php new file mode 100644 index 0000000..07a9df6 --- /dev/null +++ b/src/Middleware/UrlGeneratorMiddleware.php @@ -0,0 +1,23 @@ +handle($request->withAttribute(self::ATTRIBUTE, $this->urlGenerator)); + } +} diff --git a/tests/Integration/ApplicationBuilderTest.php b/tests/Integration/ApplicationBuilderTest.php new file mode 100644 index 0000000..0915a3c --- /dev/null +++ b/tests/Integration/ApplicationBuilderTest.php @@ -0,0 +1,236 @@ +handle($request); + } + ); + }; + + $json = static function (ResponseFactoryInterface $responseFactory, mixed $data, int $status = 200) { + $response = $responseFactory->createResponse($status)->withHeader('Content-Type', 'application/json'); + $response->getBody()->write(json_encode($data, JSON_THROW_ON_ERROR)); + + return $response; + }; + + $pingHandler = new CallbackRequestHandler( + static fn () => $json($responseFactory, ['datetime' => '2026-08-14T12:00:00.000Z']) + ); + + $petListHandler = new CallbackRequestHandler( + static fn () => $json($responseFactory, [['id' => '1'], ['id' => '2']]) + ); + + $petCreateHandler = new CallbackRequestHandler( + static fn () => $json($responseFactory, ['id' => '1'], 201) + ); + + $petReadHandler = new CallbackRequestHandler( + static function (ServerRequestInterface $request) use ($json, $responseFactory) { + /** @var UrlGeneratorInterface $urlGenerator */ + $urlGenerator = $request->getAttribute(UrlGeneratorMiddleware::ATTRIBUTE); + + return $json($responseFactory, [ + 'id' => $request->getAttribute('id'), + 'path' => $urlGenerator->generatePath('pet_read', ['id' => $request->getAttribute('id')]), + ]); + } + ); + + $petDeleteHandler = new CallbackRequestHandler( + static fn () => $responseFactory->createResponse(204) + ); + + // a minimal router implementation, matching by method + path, resolving {id} + $createRouteMatcher = static function (RoutesByNameInterface $routesByName): RouteMatcherInterface { + return new class($routesByName) implements RouteMatcherInterface { + public function __construct(private readonly RoutesByNameInterface $routesByName) {} + + public function match(ServerRequestInterface $request): RouteInterface + { + $path = $request->getUri()->getPath(); + + foreach ($this->routesByName->getRoutesByName() as $route) { + if ($route->getMethod() !== $request->getMethod()) { + continue; + } + + $pattern = '#^'.preg_replace('#\{([a-z]+)\}#', '(?P<$1>[^/]+)', $route->getPath()).'$#'; + + if (1 === preg_match($pattern, $path, $matches)) { + return $route->withAttributes( + array_filter($matches, static fn ($key) => \is_string($key), ARRAY_FILTER_USE_KEY) + ); + } + } + + throw HttpException::createNotFound([ + 'detail' => \sprintf('The path "%s" you are looking for could not be found.', $path), + ]); + } + }; + }; + + $createUrlGenerator = static function (RoutesByNameInterface $routesByName): UrlGeneratorInterface { + return new class($routesByName) implements UrlGeneratorInterface { + public function __construct(private readonly RoutesByNameInterface $routesByName) {} + + public function generateUrl( + ServerRequestInterface $request, + string $name, + array $attributes = [], + array $queryParams = [] + ): string { + return 'https://example.com'.$this->generatePath($name, $attributes, $queryParams); + } + + public function generatePath(string $name, array $attributes = [], array $queryParams = []): string + { + $path = $this->routesByName->getRoutesByName()[$name]->getPath(); + + foreach ($attributes as $key => $value) { + $path = str_replace('{'.$key.'}', $value, $path); + } + + return $path; + } + }; + }; + + $app = ApplicationBuilder::create( + $responseFactory, + $createRouteMatcher, + [$createMiddleware('cors')], + $createUrlGenerator, + true + ) + ->get('/ping', 'ping', $pingHandler) + ->group( + '/api/pets', + [$createMiddleware('acceptNegotiation'), $createMiddleware('apiError')], + static fn (GroupCollector $pets) => $pets + ->get('', 'pet_list', $petListHandler) + ->post('', 'pet_create', [$createMiddleware('contentTypeNegotiation')], $petCreateHandler) + ->get('/{id}', 'pet_read', $petReadHandler) + ->delete('/{id}', 'pet_delete', $petDeleteHandler) + ) + ->build() + ; + + $response = $app->handle($serverRequestFactory->createServerRequest('GET', 'https://example.com/ping')); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('{"datetime":"2026-08-14T12:00:00.000Z"}', (string) $response->getBody()); + self::assertSame(['cors'], $middlewareLog); + + $middlewareLog = []; + $response = $app->handle($serverRequestFactory->createServerRequest('GET', 'https://example.com/api/pets')); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('[{"id":"1"},{"id":"2"}]', (string) $response->getBody()); + self::assertSame(['cors', 'acceptNegotiation', 'apiError'], $middlewareLog); + + $middlewareLog = []; + $response = $app->handle($serverRequestFactory->createServerRequest('POST', 'https://example.com/api/pets')); + self::assertSame(201, $response->getStatusCode()); + self::assertSame('{"id":"1"}', (string) $response->getBody()); + self::assertSame(['cors', 'acceptNegotiation', 'apiError', 'contentTypeNegotiation'], $middlewareLog); + + $middlewareLog = []; + $response = $app->handle($serverRequestFactory->createServerRequest('GET', 'https://example.com/api/pets/1')); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('{"id":"1","path":"\/api\/pets\/1"}', (string) $response->getBody()); + self::assertSame(['cors', 'acceptNegotiation', 'apiError'], $middlewareLog); + + $middlewareLog = []; + $response = $app->handle( + $serverRequestFactory->createServerRequest('DELETE', 'https://example.com/api/pets/1') + ); + self::assertSame(204, $response->getStatusCode()); + self::assertSame('', (string) $response->getBody()); + self::assertSame(['cors', 'acceptNegotiation', 'apiError'], $middlewareLog); + + $middlewareLog = []; + $response = $app->handle($serverRequestFactory->createServerRequest('GET', 'https://example.com/unknown')); + self::assertSame(404, $response->getStatusCode()); + self::assertStringContainsString('Not Found', (string) $response->getBody()); + self::assertSame(['cors'], $middlewareLog); + } + + public static function provideUsageExampleCases(): iterable + { + return [ + 'guzzle' => [ + 'responseFactory' => new GuzzleResponseFactory(), + 'serverRequestFactory' => new GuzzleServerRequestFactory(), + ], + 'laminas' => [ + 'responseFactory' => new LaminasResponseFactory(), + 'serverRequestFactory' => new LaminasServerRequestFactory(), + ], + 'nyholm' => [ + 'responseFactory' => new NyholmResponseFactory(), + 'serverRequestFactory' => new NyholmServerRequestFactory(), + ], + 'slim' => [ + 'responseFactory' => new SlimResponseFactory(), + 'serverRequestFactory' => new SlimServerRequestFactory(), + ], + 'sunrise' => [ + 'responseFactory' => new SunriseResponseFactory(), + 'serverRequestFactory' => new SunriseServerRequestFactory(), + ], + ]; + } +} diff --git a/tests/Integration/DocumentationTest.php b/tests/Integration/DocumentationTest.php index b2dbf5f..662a7fe 100644 --- a/tests/Integration/DocumentationTest.php +++ b/tests/Integration/DocumentationTest.php @@ -37,7 +37,7 @@ public function testDocumentation(): void } } - self::assertSame(41, $phpBlockCount); + self::assertSame(56, $phpBlockCount); } private function getDocumentationFiles(string $path): array diff --git a/tests/Unit/Facade/ApplicationBuilderTest.php b/tests/Unit/Facade/ApplicationBuilderTest.php new file mode 100644 index 0000000..a34de5b --- /dev/null +++ b/tests/Unit/Facade/ApplicationBuilderTest.php @@ -0,0 +1,414 @@ +create(ResponseFactoryInterface::class, []); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, []); + + $routeMatcherCalls = []; + + $createRouteMatcher = static function (RoutesByNameInterface $routesByName) use ( + $builder, + &$routeMatcherCalls + ): RouteMatcherInterface { + $routeMatcherCalls[] = array_keys($routesByName->getRoutesByName()); + + /** @var RouteMatcherInterface $routeMatcher */ + $routeMatcher = $builder->create(RouteMatcherInterface::class, []); + + return $routeMatcher; + }; + + $applicationBuilder = ApplicationBuilder::create($responseFactory, $createRouteMatcher); + $applicationBuilderWithRoute = $applicationBuilder->get('/ping', 'ping', $handler); + + self::assertNotSame($applicationBuilder, $applicationBuilderWithRoute); + + self::assertInstanceOf(Application::class, $applicationBuilder->build()); + self::assertInstanceOf(Application::class, $applicationBuilderWithRoute->build()); + + self::assertSame([[], ['ping']], $routeMatcherCalls); + } + + public function testBuildMinimal(): void + { + $builder = new MockObjectBuilder(); + + /** @var ResponseInterface $response */ + $response = $builder->create(ResponseInterface::class, []); + + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $builder->create(ResponseFactoryInterface::class, []); + + $route = null; + + /** @var ServerRequestInterface $request */ + $request = $builder->create(ServerRequestInterface::class, [ + new WithCallback('withAttribute', static function (string $name, mixed $value) use (&$route, &$request) { + self::assertSame('route', $name); + self::assertSame($route, $value); + + return $request; + }), + new WithCallback('getAttribute', static function (string $name, mixed $default) use (&$route) { + self::assertSame('route', $name); + self::assertNull($default); + + return $route; + }), + ]); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, [ + new WithReturn('handle', [$request], $response), + ]); + + /** @var RouteInterface $route */ + $route = $builder->create(RouteInterface::class, [ + new WithReturn('getAttributes', [], []), + new WithReturn('getMiddlewares', [], []), + new WithReturn('getRequestHandler', [], $handler), + ]); + + /** @var RouteMatcherInterface $routeMatcher */ + $routeMatcher = $builder->create(RouteMatcherInterface::class, [ + new WithReturn('match', [$request], $route), + ]); + + $createRouteMatcher = static function (RoutesByNameInterface $routesByName) use ($routeMatcher, $handler) { + $routesByName = $routesByName->getRoutesByName(); + + self::assertSame(['ping'], array_keys($routesByName)); + self::assertSame('GET', $routesByName['ping']->getMethod()); + self::assertSame('/ping', $routesByName['ping']->getPath()); + self::assertSame([], $routesByName['ping']->getMiddlewares()); + self::assertSame([], $routesByName['ping']->getPathOptions()); + self::assertSame($handler, $routesByName['ping']->getRequestHandler()); + + return $routeMatcher; + }; + + $application = ApplicationBuilder::create($responseFactory, $createRouteMatcher) + ->get('/ping', 'ping', $handler) + ->build() + ; + + self::assertSame($response, $application->handle($request)); + } + + public function testBuildMaximal(): void + { + $builder = new MockObjectBuilder(); + + /** @var ResponseInterface $response */ + $response = $builder->create(ResponseInterface::class, []); + + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $builder->create(ResponseFactoryInterface::class, []); + + /** @var LoggerInterface $logger */ + $logger = $builder->create(LoggerInterface::class, []); + + /** @var UrlGeneratorInterface $urlGenerator */ + $urlGenerator = $builder->create(UrlGeneratorInterface::class, []); + + /** @var MiddlewareInterface $appMiddleware */ + $appMiddleware = $builder->create(MiddlewareInterface::class, [ + new WithCallback( + 'process', + static fn ( + ServerRequestInterface $request, + RequestHandlerInterface $requestHandler + ) => $requestHandler->handle($request) + ), + ]); + + /** @var MiddlewareInterface $groupMiddleware */ + $groupMiddleware = $builder->create(MiddlewareInterface::class, [ + new WithCallback( + 'process', + static fn ( + ServerRequestInterface $request, + RequestHandlerInterface $requestHandler + ) => $requestHandler->handle($request) + ), + ]); + + /** @var MiddlewareInterface $routeMiddleware */ + $routeMiddleware = $builder->create(MiddlewareInterface::class, [ + new WithCallback( + 'process', + static fn ( + ServerRequestInterface $request, + RequestHandlerInterface $requestHandler + ) => $requestHandler->handle($request) + ), + ]); + + $route = null; + + $withAttributeCalls = []; + + /** @var ServerRequestInterface $request */ + $request = $builder->create(ServerRequestInterface::class, [ + new WithCallback('withAttribute', static function (string $name, mixed $value) use ( + &$withAttributeCalls, + &$request + ) { + $withAttributeCalls[] = [$name, $value]; + + return $request; + }), + new WithCallback('withAttribute', static function (string $name, mixed $value) use ( + &$withAttributeCalls, + &$request + ) { + $withAttributeCalls[] = [$name, $value]; + + return $request; + }), + new WithCallback('withAttribute', static function (string $name, mixed $value) use ( + &$withAttributeCalls, + &$request + ) { + $withAttributeCalls[] = [$name, $value]; + + return $request; + }), + new WithCallback('getAttribute', static function (string $name, mixed $default) use (&$route) { + self::assertSame('route', $name); + self::assertNull($default); + + return $route; + }), + ]); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, [ + new WithReturn('handle', [$request], $response), + ]); + + /** @var RouteInterface $route */ + $route = $builder->create(RouteInterface::class, [ + new WithReturn('getAttributes', [], ['id' => '1']), + new WithReturn('getMiddlewares', [], [$groupMiddleware, $routeMiddleware]), + new WithReturn('getRequestHandler', [], $handler), + ]); + + /** @var RouteMatcherInterface $routeMatcher */ + $routeMatcher = $builder->create(RouteMatcherInterface::class, [ + new WithReturn('match', [$request], $route), + ]); + + $expectRoutesByName = static function (RoutesByNameInterface $routesByName) use ( + $handler, + $groupMiddleware, + $routeMiddleware + ): void { + $routesByName = $routesByName->getRoutesByName(); + + self::assertSame(['pet_list', 'pet_read'], array_keys($routesByName)); + + self::assertSame('GET', $routesByName['pet_list']->getMethod()); + self::assertSame('/api/pets', $routesByName['pet_list']->getPath()); + self::assertSame([$groupMiddleware], $routesByName['pet_list']->getMiddlewares()); + self::assertSame(['tokens' => ['version' => '\d+']], $routesByName['pet_list']->getPathOptions()); + self::assertSame($handler, $routesByName['pet_list']->getRequestHandler()); + + self::assertSame('GET', $routesByName['pet_read']->getMethod()); + self::assertSame('/api/pets/{id}', $routesByName['pet_read']->getPath()); + self::assertSame([$groupMiddleware, $routeMiddleware], $routesByName['pet_read']->getMiddlewares()); + self::assertSame( + ['tokens' => ['version' => '\d+', 'id' => '\d+']], + $routesByName['pet_read']->getPathOptions() + ); + self::assertSame($handler, $routesByName['pet_read']->getRequestHandler()); + }; + + $createRouteMatcher = static function (RoutesByNameInterface $routesByName) use ( + $expectRoutesByName, + $routeMatcher + ): RouteMatcherInterface { + $expectRoutesByName($routesByName); + + return $routeMatcher; + }; + + $createUrlGenerator = static function (RoutesByNameInterface $routesByName) use ( + $expectRoutesByName, + $urlGenerator + ): UrlGeneratorInterface { + $expectRoutesByName($routesByName); + + return $urlGenerator; + }; + + $application = ApplicationBuilder::create( + $responseFactory, + $createRouteMatcher, + [$appMiddleware], + $createUrlGenerator, + true, + $logger + ) + ->group( + '/api/pets', + [$groupMiddleware], + static fn (GroupCollector $pets) => $pets + ->get('', 'pet_list', $handler) + ->get('/{id}', 'pet_read', [$routeMiddleware], $handler, ['tokens' => ['id' => '\d+']]), + ['tokens' => ['version' => '\d+']] + ) + ->build() + ; + + self::assertSame($response, $application->handle($request)); + + self::assertSame( + [['urlGenerator', $urlGenerator], ['route', $route], ['id', '1']], + $withAttributeCalls + ); + } + + public function testBuildWithExceptionMiddleware(): void + { + $builder = new MockObjectBuilder(); + + /** @var StreamInterface $body */ + $body = $builder->create(StreamInterface::class, [ + new WithCallback('write', static function (string $html): int { + self::assertStringContainsString('Not Found', $html); + self::assertStringContainsString( + '

The path "/unknown" you are looking for could not be found.

', + $html + ); + self::assertStringContainsString('Chubbyphp\HttpException\HttpException', $html); + + return \strlen($html); + }), + ]); + + /** @var ResponseInterface $response */ + $response = $builder->create(ResponseInterface::class, [ + new WithReturnSelf('withHeader', ['Content-Type', 'text/html']), + new WithReturn('getBody', [], $body), + ]); + + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $builder->create(ResponseFactoryInterface::class, [ + new WithReturn('createResponse', [404, ''], $response), + ]); + + /** @var LoggerInterface $logger */ + $logger = $builder->create(LoggerInterface::class, [ + new WithCallback('info', static function (string $message, array $context): void { + self::assertSame('Http Exception', $message); + self::assertSame(404, $context['data']['status']); + }), + ]); + + /** @var ServerRequestInterface $request */ + $request = $builder->create(ServerRequestInterface::class, []); + + /** @var RouteMatcherInterface $routeMatcher */ + $routeMatcher = $builder->create(RouteMatcherInterface::class, [ + new WithCallback('match', static function (ServerRequestInterface $request): never { + throw HttpException::createNotFound([ + 'detail' => 'The path "/unknown" you are looking for could not be found.', + ]); + }), + ]); + + $application = ApplicationBuilder::create( + $responseFactory, + static fn (RoutesByNameInterface $routesByName) => $routeMatcher, + [], + null, + true, + $logger + )->build(); + + self::assertSame($response, $application->handle($request)); + } + + public function testBuildWithExceptionMiddlewareDefaults(): void + { + $builder = new MockObjectBuilder(); + + /** @var StreamInterface $body */ + $body = $builder->create(StreamInterface::class, [ + new WithCallback('write', static function (string $html): int { + self::assertStringContainsString('Not Found', $html); + self::assertStringNotContainsString('Chubbyphp\HttpException\HttpException', $html); + + return \strlen($html); + }), + ]); + + /** @var ResponseInterface $response */ + $response = $builder->create(ResponseInterface::class, [ + new WithReturnSelf('withHeader', ['Content-Type', 'text/html']), + new WithReturn('getBody', [], $body), + ]); + + /** @var ResponseFactoryInterface $responseFactory */ + $responseFactory = $builder->create(ResponseFactoryInterface::class, [ + new WithReturn('createResponse', [404, ''], $response), + ]); + + /** @var ServerRequestInterface $request */ + $request = $builder->create(ServerRequestInterface::class, []); + + /** @var RouteMatcherInterface $routeMatcher */ + $routeMatcher = $builder->create(RouteMatcherInterface::class, [ + new WithCallback('match', static function (ServerRequestInterface $request): never { + throw HttpException::createNotFound(); + }), + ]); + + $application = ApplicationBuilder::create( + $responseFactory, + static fn (RoutesByNameInterface $routesByName) => $routeMatcher, + )->build(); + + self::assertSame($response, $application->handle($request)); + } +} diff --git a/tests/Unit/Facade/GroupCollectorTest.php b/tests/Unit/Facade/GroupCollectorTest.php new file mode 100644 index 0000000..3429f32 --- /dev/null +++ b/tests/Unit/Facade/GroupCollectorTest.php @@ -0,0 +1,172 @@ +getChildren()); + } + + public function testIsImmutable(): void + { + $builder = new MockObjectBuilder(); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, []); + + $collector = GroupCollector::create(); + $collectorWithRoute = $collector->get('/ping', 'ping', $handler); + + self::assertNotSame($collector, $collectorWithRoute); + self::assertSame([], $collector->getChildren()); + self::assertCount(1, $collectorWithRoute->getChildren()); + } + + public function testRoutesAndGroups(): void + { + $builder = new MockObjectBuilder(); + + /** @var MiddlewareInterface $middleware1 */ + $middleware1 = $builder->create(MiddlewareInterface::class, []); + + /** @var MiddlewareInterface $middleware2 */ + $middleware2 = $builder->create(MiddlewareInterface::class, []); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, []); + + $collector = GroupCollector::create() + ->route('TRACE', '/trace', 'trace', [$middleware1], $handler, ['tokens' => ['trace' => '\d+']]) + ->delete('/delete', 'delete', $handler, ['tokens' => ['delete' => '\d+']]) + ->get('/get', 'get', $handler) + ->head('/head', 'head', $handler) + ->options('/options', 'options', $handler) + ->patch('/patch', 'patch', $handler) + ->post('/post', 'post', $handler) + ->put('/put', 'put', $handler) + ->group( + '/group', + [$middleware1], + static fn (GroupCollector $group) => $group + ->get('/{id}', 'group_get', [$middleware2], $handler, ['tokens' => ['id' => '\d+']]), + ['tokens' => ['group' => '[a-z]+']] + ) + ->group( + '/other', + static fn (GroupCollector $group) => $group + ->get('/{id}', 'other_get', $handler), + ['tokens' => ['other' => '[a-z]+']] + ) + ; + + $children = $collector->getChildren(); + + self::assertCount(10, $children); + + $routes = Group::create('', $children)->getRoutes(); + + self::assertCount(10, $routes); + + self::assertSame( + [ + ['TRACE', '/trace', 'trace', [$middleware1], ['tokens' => ['trace' => '\d+']]], + ['DELETE', '/delete', 'delete', [], ['tokens' => ['delete' => '\d+']]], + ['GET', '/get', 'get', [], []], + ['HEAD', '/head', 'head', [], []], + ['OPTIONS', '/options', 'options', [], []], + ['PATCH', '/patch', 'patch', [], []], + ['POST', '/post', 'post', [], []], + ['PUT', '/put', 'put', [], []], + [ + 'GET', + '/group/{id}', + 'group_get', + [$middleware1, $middleware2], + ['tokens' => ['group' => '[a-z]+', 'id' => '\d+']], + ], + ['GET', '/other/{id}', 'other_get', [], ['tokens' => ['other' => '[a-z]+']]], + ], + array_map( + static fn (RouteInterface $route) => [ + $route->getMethod(), + $route->getPath(), + $route->getName(), + $route->getMiddlewares(), + $route->getPathOptions(), + ], + $routes + ) + ); + + foreach ($routes as $route) { + self::assertSame($handler, $route->getRequestHandler()); + } + } + + public function testRouteWithHandlerAndInvalidThirdArgument(): void + { + $builder = new MockObjectBuilder(); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, []); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'route(): with the request handler as content parameter, the next one must be pathOptions (array)' + ); + + GroupCollector::create()->get('/ping', 'ping', $handler, $handler); + } + + public function testRouteWithMiddlewaresAndMissingHandler(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'route(): with middlewares as first content parameter, the next one must be the request handler' + ); + + GroupCollector::create()->get('/ping', 'ping', []); + } + + public function testGroupWithConfigureAndInvalidThirdArgument(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'group(): with configure as second parameter, the third one must be pathOptions (array)' + ); + + GroupCollector::create()->group( + '/group', + static fn (GroupCollector $group) => $group, + static fn (GroupCollector $group) => $group + ); + } + + public function testGroupWithMiddlewaresAndMissingConfigure(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'group(): with middlewares as second parameter, the third one must be the configure callback' + ); + + GroupCollector::create()->group('/group', []); + } +} diff --git a/tests/Unit/Middleware/UrlGeneratorMiddlewareTest.php b/tests/Unit/Middleware/UrlGeneratorMiddlewareTest.php new file mode 100644 index 0000000..c6d9fdb --- /dev/null +++ b/tests/Unit/Middleware/UrlGeneratorMiddlewareTest.php @@ -0,0 +1,48 @@ +create(UrlGeneratorInterface::class, []); + + /** @var ServerRequestInterface $request */ + $request = $builder->create(ServerRequestInterface::class, [ + new WithReturnSelf('withAttribute', ['urlGenerator', $urlGenerator]), + ]); + + /** @var ResponseInterface $response */ + $response = $builder->create(ResponseInterface::class, []); + + /** @var RequestHandlerInterface $handler */ + $handler = $builder->create(RequestHandlerInterface::class, [ + new WithReturn('handle', [$request], $response), + ]); + + $middleware = new UrlGeneratorMiddleware($urlGenerator); + + self::assertSame($response, $middleware->process($request, $handler)); + } +}