diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 706d6406..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,64 +0,0 @@ -# PHP CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-php/ for more details -# -version: 2 -jobs: - php71: - docker: - - image: cimg/php:7.1 - steps: - - checkout - - run: php -v - - run: composer install --no-interaction - - run: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-text - php72: - docker: - - image: cimg/php:7.2 - steps: - - checkout - - run: php -v - - run: composer install --no-interaction - - run: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-text - php73: - docker: - - image: cimg/php:7.3 - steps: - - checkout - - run: php -v - - run: composer install --no-interaction - - run: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-text - php74: - docker: - - image: cimg/php:7.4 - steps: - - checkout - - run: php -v - - run: composer install --no-interaction - - run: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-text - php80: - docker: - - image: cimg/php:8.0 - steps: - - checkout - - run: php -v - - run: composer install --no-interaction - - run: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-text - php81: - docker: - - image: cimg/php:8.1 - steps: - - checkout - - run: php -v - - run: composer install --no-interaction - - run: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-text -workflows: - version: 2 - build: - jobs: - - php71 - - php72 - - php73 - - php74 - - php80 - - php81 diff --git a/.editorconfig b/.editorconfig index 91aebb87..0beac55a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true -[*.yml] +[*{.json,.yml}] indent_size = 2 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1e976c3a..52a1ded1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,7 +1,7 @@ # These are supported funding model platforms github: fenric # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username +patreon: afenric open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: "packagist/sunrise/http-router" # Replace with a single Tidelift platform-name/package-name e.g., npm/babel diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 1e6a96fc..6f32340f 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,16 +1,24 @@ build: - environment: - php: - version: '8.0' + image: default-bionic nodes: analysis: + environment: + php: 8.2 tests: override: - php-scrutinizer-run coverage: + environment: + php: 8.2 tests: override: - command: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-clover coverage.xml coverage: file: coverage.xml format: clover + php81: + environment: + php: 8.1 + tests: + override: + - command: php vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index a4cc563b..b1a1074b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1 @@ -# v2.16.0 - -* New method: `Router::hasRoute(string):bool`. - -## v2.15.0 - -* New middleware: `Sunrise\Http\Router\Middleware\JsonPayloadDecodingMiddleware`. - -## v2.14.0 - -* New method: `Route::getHolder():Reflector`; -* New method: `Router::resolveHostname(string):?string`; -* New method: `Router::getRoutesByHostname(string):array`; -* New method: `RouterBuilder::setEventDispatcher(?EventDispatcherInterface):void`. - -## v2.13.0 - -* Supports for events using the `symfony/event-dispatcher`. +# v3.0.0 diff --git a/README.md b/README.md index a20787bf..d33ac903 100644 --- a/README.md +++ b/README.md @@ -1,714 +1 @@ -# HTTP router for PHP 7.1+ based on PSR-7 and PSR-15 with support for annotations/attributes and OpenAPI (Swagger) Specification - -**psr router**, **router with annotations**, **router with attributes**, **php router**. - -[![Build Status](https://circleci.com/gh/sunrise-php/http-router.svg?style=shield)](https://circleci.com/gh/sunrise-php/http-router) -[![Code Coverage](https://scrutinizer-ci.com/g/sunrise-php/http-router/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/sunrise-php/http-router/?branch=master) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/sunrise-php/http-router/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/sunrise-php/http-router/?branch=master) -[![Total Downloads](https://poser.pugx.org/sunrise/http-router/downloads?format=flat)](https://packagist.org/packages/sunrise/http-router) -[![Latest Stable Version](https://poser.pugx.org/sunrise/http-router/v/stable?format=flat)](https://packagist.org/packages/sunrise/http-router) -[![License](https://poser.pugx.org/sunrise/http-router/license?format=flat)](https://packagist.org/packages/sunrise/http-router) - ---- - -## Installation - -```bash -composer require 'sunrise/http-router:^2.15' -``` - -## Support for OpenAPI (Swagger) Specification (optional) - -```bash -composer require 'sunrise/http-router-openapi:^2.0' -``` - -More details can be found here: [sunrise/http-router-openapi](https://github.com/sunrise-php/http-router-openapi). - -## QuickStart - -This example uses other sunrise packages, but you can use e.g. `zend/diactoros` or any other. - -```bash -composer require sunrise/http-message sunrise/http-server-request -``` - -```php -use Sunrise\Http\Message\ResponseFactory; -use Sunrise\Http\Router\RouteCollector; -use Sunrise\Http\Router\Router; -use Sunrise\Http\ServerRequest\ServerRequestFactory; - -use function Sunrise\Http\Router\emit; - -$collector = new RouteCollector(); - -// PSR-15 request handler (optimal performance): -$collector->get('home', '/', new HomeRequestHandler()); - -// or you can use an anonymous function as your request handler: -$collector->get('home', '/', function ($request) { - return (new ResponseFactory)->createResponse(200); -}); - -// or you can use the name of a class that implements PSR-15: -$collector->get('home', '/', HomeRequestHandler::class); - -// or you can use a class method name as your request handler: -// (note that such a class mayn't implement PSR-15) -$collector->get('home', '/', [HomeRequestHandler::class, 'index']); - -// most likely you will need to use PSR-11 container: -// (note that only named classes will be pulled from such a container) -$collector->setContainer($container); - -$router = new Router(); -$router->addRoute(...$collector->getCollection()->all()); - -$request = ServerRequestFactory::fromGlobals(); -$response = $router->handle($request); - -emit($response); -``` - ---- - -## Examples of using - -Study [sunrise/awesome-skeleton](https://github.com/sunrise-php/awesome-skeleton) to understand how this can be used. - -#### Strategy for loading routes from configs - -> Please note that since version 2.10.0 class `ConfigLoader` must be used. - -```php -use Sunrise\Http\Router\Loader\ConfigLoader; -use Sunrise\Http\Router\Router; - -$loader = new ConfigLoader(); - -// set container if necessary... -$loader->setContainer($container); - -// attach configs... -$loader->attach('routes/api.php'); -$loader->attach('routes/admin.php'); -$loader->attach('routes/public.php'); - -// or attach a directory... -// [!] available from version 2.2 -$loader->attach('routes'); - -// or attach an array... -// [!] available from version 2.4 -$loader->attachArray([ - 'routes/api.php', - 'routes/admin.php', - 'routes/public.php', -]); - -// install container if necessary... -$loader->setContainer($container); - -$router = new Router(); -$router->load($loader); - -// if the router matching should be isolated for top middlewares... -// for example for error handling... -// [!] available from version 2.8 -$response = $router->run($request); - -// if the router is used as a request handler -$response = $router->handle($request); - -// if the router is used as middleware -$response = $router->process($request, $handler); -``` - -```php -/** @var Sunrise\Http\Router\RouteCollector $this */ - -$this->get('home', '/', new CallableRequestHandler(function ($request) { - return (new ResponseFactory)->createJsonResponse(200); -})); - -// or using a direct reference to a request handler... -$this->get('home', '/', new App\Http\Controller\HomeController()); -``` - -> Please note that since version 2.10.0 you can refer to the request handler in different ways. - -```php -/** @var Sunrise\Http\Router\RouteCollector $this */ - -$this->get('home', '/', function ($request) { - return (new ResponseFactory)->createJsonResponse(200); -}); - -$this->get('home', '/', App\Http\Controller\HomeController::class, [ - App\Http\Middleware\FooMiddleware::class, - App\Http\Middleware\BarMiddleware::class, -]); - -$this->get('home', '/', [App\Http\Controller\HomeController::class, 'index'], [ - App\Http\Middleware\FooMiddleware::class, - App\Http\Middleware\BarMiddleware::class, -]); -``` - -#### Strategy for loading routes from descriptors (annotations or attributes) - -Install the [doctrine/annotations](https://github.com/doctrine/annotations) package if you will be use annotations: - -```bash -composer require doctrine/annotations -``` - -> Please note that since version 2.10.0 class `DescriptorLoader` must be used. - -> Please note that since version 2.10.0 you can bind the @Rote() annotation to a class methods. - -```php -use Doctrine\Common\Annotations\AnnotationRegistry; -use Sunrise\Http\Router\Loader\DescriptorLoader; -use Sunrise\Http\Router\Router; - -// necessary if you will use annotations (annotations isn't attributes)... -AnnotationRegistry::registerLoader('class_exists'); - -$loader = new DescriptorLoader(); - -// set container if necessary... -$loader->setContainer($container); - -// attach a directory with controllers... -$loader->attach('src/Controller'); - -// or attach an array -// [!] available from version 2.4 -$loader->attachArray([ - 'src/Controller', - 'src/Bundle/BundleName/Controller', -]); - -// or attach a class only -// [!] available from 2.10 version. -$loader->attach(App\Http\Controller\FooController::class); - -$router = new Router(); -$router->load($loader); - -// if the router matching should be isolated for top middlewares... -// for example for error handling... -// [!] available from version 2.8 -$response = $router->run($request); - -// if the router is used as a request handler -$response = $router->handle($request); - -// if the router is used as middleware -$response = $router->process($request, $handler); -``` - -```php -use Sunrise\Http\Router\Annotation as Mapping; - -#[Mapping\Prefix('/api/v1')] -#[Mapping\Middleware(SomeMiddleware::class)] -class SomeController { - - #[Mapping\Route('foo', path: '/foo')] - public function foo() { - // will be available at: /api/v1/foo - } - - #[Mapping\Route('bar', path: '/bar')] - public function bar() { - // will be available at: /api/v1/bar - } -} -``` - -#### Without loading strategy - -```php -use App\Controller\HomeController; -use Sunrise\Http\Router\RouteCollector; -use Sunrise\Http\Router\Router; - -$collector = new RouteCollector(); - -// set container if necessary... -$collector->setContainer($container); - -$collector->get('home', '/', new HomeController()); - -$router = new Router(); -$router->addRoute(...$collector->getCollection()->all()); - -// if the router matching should be isolated for top middlewares... -// for example for error handling... -// [!] available from version 2.8 -$response = $router->run($request); - -// if the router is used as a request handler -$response = $router->handle($request); - -// if the router is used as middleware -$response = $router->process($request, $handler); -``` - -#### Error handling example - -```php -use Sunrise\Http\Message\ResponseFactory; -use Sunrise\Http\Router\Exception\MethodNotAllowedException; -use Sunrise\Http\Router\Exception\RouteNotFoundException; -use Sunrise\Http\Router\Middleware\CallableMiddleware; -use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; -use Sunrise\Http\Router\RouteCollector; -use Sunrise\Http\Router\Router; -use Sunrise\Http\ServerRequest\ServerRequestFactory; - -use function Sunrise\Http\Router\emit; - -$collector = new RouteCollector(); - -$collector->get('home', '/', new CallableRequestHandler(function ($request) { - return (new ResponseFactory)->createJsonResponse(200); -})); - -$router = new Router(); -$router->addRoute(...$collector->getCollection()->all()); - -$router->addMiddleware(new CallableMiddleware(function ($request, $handler) { - try { - return $handler->handle($request); - } catch (MethodNotAllowedException $e) { - return (new ResponseFactory)->createResponse(405); - } catch (RouteNotFoundException $e) { - return (new ResponseFactory)->createResponse(404); - } catch (Throwable $e) { - return (new ResponseFactory)->createResponse(500); - } -})); - -emit($router->run(ServerRequestFactory::fromGlobals())); -``` - -#### Work with PSR-11 container - -##### Collector - -```php -$collector = new RouteCollector(); - -/** @var \Psr\Container\ContainerInterface $container */ - -// Pass DI container to the collector... -$collector->setContainer($container); - -// Objects passed as strings will be initialized through the DI container... -$route = $collector->get('home', '/', HomeController::class, [ - FooMiddleware::class, - BarMiddleware::class, -]); -``` - -##### Config loader - -```php -$loader = new ConfigLoader(); - -/** @var \Psr\Container\ContainerInterface $container */ - -// Pass DI container to the loader... -$loader->setContainer($container); - -// All found objects which has been passed as strings will be initialized through the DI container... -$routes = $loader->load(); -``` - -##### Descriptor loader - -```php -$loader = new DescriptorLoader(); - -/** @var \Psr\Container\ContainerInterface $container */ - -// Pass DI container to the loader... -$loader->setContainer($container); - -// All found objects will be initialized through the DI container... -$routes = $loader->load(); -``` - -#### Descriptors cache (PSR-16) - -```php -$loader = new DescriptorLoader(); - -/** @var \Psr\SimpleCache\CacheInterface $cache */ - -// Pass a cache to the loader... -$loader->setCache($cache); -``` - -#### Route Annotation Example - -##### Minimal annotation view - -```php -/** - * @Route( - * name="api_v1_entry_update", - * path="/api/v1/entry/{id<@uuid>}(/{optionalAttribute})", - * methods={"PATCH"}, - * ) - */ -final class EntryUpdateRequestHandler implements RequestHandlerInterface -``` - -##### Full annotation - -```php -/** - * @Route( - * name="api_v1_entry_update", - * host="api.host", - * path="/api/v1/entry/{id<@uuid>}(/{optionalAttribute})", - * methods={"PATCH"}, - * middlewares={ - * "App\Middleware\CorsMiddleware", - * "App\Middleware\ApiAuthMiddleware", - * }, - * attributes={ - * "optionalAttribute": "defaultValue", - * }, - * summary="Updates an entry by UUID", - * description="Here you can describe the method in more detail...", - * tags={"api", "entry"}, - * priority=0, - * ) - */ -final class EntryUpdateRequestHandler implements RequestHandlerInterface -``` - -##### One method only - -```php -/** - * @Route( - * name="home", - * path="/", - * method="GET", - * ) - */ -``` - -#### Route Attribute Example - -##### Minimal attribute view - -```php -use Sunrise\Http\Router\Annotation\Route; - -#[Route( - name: 'api_v1_entry_update', - path: '/api/v1/entry/{id<@uuid>}(/{optionalAttribute})', - methods: ['PATCH'], -)] -final class EntryUpdateRequestHandler implements RequestHandlerInterface -``` - -##### Full attribute - -```php -use Sunrise\Http\Router\Annotation\Route; - -#[Route( - name: 'api_v1_entry_update', - host: 'api.host', - path: '/api/v1/entry/{id<@uuid>}(/{optionalAttribute})', - methods: ['PATCH'], - middlewares: [ - \App\Middleware\CorsMiddleware::class, - \App\Middleware\ApiAuthMiddleware::class, - ], - attributes: [ - 'optionalAttribute' => 'defaultValue', - ], - summary: 'Updates an entry by UUID', - description: 'Here you can describe the method in more detail...', - tags: ['api', 'entry'], - priority: 0, -)] -final class EntryUpdateRequestHandler implements RequestHandlerInterface -``` - -##### Additional annotations - -```php -use Sunrise\Http\Router\Annotation\Host; - -#[Host('admin')] -#[Prefix('/api/v1')] -#[Postfix('.json')] -#[Middleware(SomeMiddleware::class)] -final class SomeController -{ - #[Route('foo', '/foo')] - public function foo(ServerRequestInterface $request) : ResponseInterface - { - // this action will be available at: - // http://admin.host/api/v1/foo.json - // - // this can be handy to reduce code duplication... - } -} -``` - ---- - -## Useful to know - -### JSON-payload decoding - -```php -use Sunrise\Http\Router\Middleware\JsonPayloadDecodingMiddleware; - -$router->addMiddleware(new JsonPayloadDecodingMiddleware()); -``` - -### Get a route by name - -```php -// checks if a route is exists -$router->hasRoute('foo'); - -// gets a route by name -$router->getRoute('foo'); -``` - -### Get a current route - -#### Through Router - -> Available from version 2.12. - -```php -$router->getMatchedRoute(); -``` - -#### Through Request - -> Available from version 1.x, but wasn't documented before... - -```php -$request->getAttribute('@route'); - -// or -$request->getAttribute(\Sunrise\Http\Router\RouteInterface::ATTR_ROUTE); -``` - -#### Through Event - -> Available from version 2.13. - -```php -$eventDispatcher->addListener(RouteEvent::NAME, function (RouteEvent $event) { - $event->getRoute(); -}); -``` - -### Generation a route URI - -```php -$uri = $router->generateUri('route.name', [ - 'attribute' => 'value', -], true); -``` - -### Run a route - -```php -$response = $router->getRoute('route.name')->handle($request); -``` - -### Route grouping - -Example for annotations [here](#additional-annotations). - -```php -$collector->group(function ($collector) { - $collector->group(function ($collector) { - $collector->group(function ($collector) { - $collector->get('api.entry.read', '/{id<\d+>}', ...) - ->addMiddleware(...); // add the middleware(s) to the route... - }) - ->addPrefix('/entry') // add the prefix to the group... - ->prependMiddleware(...); // add the middleware(s) to the group... - }, [ - App\Http\Middleware\Bar::class, // resolvable middlewares... - ]) - ->addPrefix('/v1') // add the prefix to the group... - ->prependMiddleware(...); // add the middleware(s) to the group... -}, [ - App\Http\Middleware\Foo::class, // resolvable middlewares... -]) -->addPrefix('/api') // add the prefix to the group... -->prependMiddleware(...); // add the middleware(s) to the group... -``` - -### Route patterns - -```php -$collector->get('api.entry.read', '/api/v1/entry/{id<\d+>}(/{optional<\w+>})'); -``` - -##### Global route patterns - -```php -// @uuid pattern -$collector->get('api.entry.read', '/api/v1/entry/{id<@uuid>}'); - -// @slug pattern -$collector->get('api.entry.read', '/api/v1/entry/{slug<@slug>}'); - -// Custom patterns (available from version 2.9.0): -\Sunrise\Http\Router\Router::$patterns['@id'] = '[1-9][0-9]*'; - -// Just use the custom pattern... -$collector->get('api.entry.read', '/api/v1/entry/{id<@id>}'); -``` - -It is better to set patterns through the router: - -```php -// available since version 2.11.0 -$router->addPatterns([ - '@id' => '[1-9][0-9]*', -]); -``` - -...or through the router's builder: - -```php -// available since version 2.11.0 -$builder->setPatterns([ - '@id' => '[1-9][0-9]*', -]); -``` - -### Hosts (available from version 2.6.0) - -> Note: if you don't assign a host for a route, it will be available on any hosts! - -```php -// move the hosts table into the settings... -$router->addHost('public.host', 'www.example.com', ...); -$router->addHost('admin.host', 'secret.example.com', ...); -$router->addHost('api.host', 'api.example.com', ...); - -// ...or: -$router->addHosts([ - 'public.host' => ['www.example.com', ...], - ... -]); - -// the route will available only on the `secret.example.com` host... -$route->setHost('admin.host'); - -// routes in the group will available on the `secret.example.com` host... -$collector->group(function ($collector) { - // some code... -}) -->setHost('admin.host'); -``` - -You can resolve the hostname since version 2.14.0 as follows: - -```php -$router->addHost('admin', 'www1.admin.example.com', 'www2.admin.example.com'); - -$router->resolveHostname('www1.admin.example.com'); // return "admin" -$router->resolveHostname('www2.admin.example.com'); // return "admin" -$router->resolveHostname('unknown'); // return null -``` - -Also you can get all routes by hostname: - -```php -$router->getRoutesByHostname('www1.admin.example.com'); -``` - -### Route Holder - -```php -$route->getHolder(); // return Reflector (class, method or function) -``` - -### The router builder - -```php -$router = (new RouterBuilder) - ->setEventDispatcher(...) // null or use to symfony/event-dispatcher... - ->setContainer(...) // null or PSR-11 container instance... - ->setCache(...) // null or PSR-16 cache instance... (only for descriptor loader) - ->setCacheKey(...) // null or string... (only for descriptor loader) - ->useConfigLoader([]) // array with files or directory with files... - ->useDescriptorLoader([]) // array with classes or directory with classes... - ->setHosts([]) // - ->setMiddlewares([]) // array with middlewares... - ->setPatterns([]) // available since version 2.11.0 - ->build(); -``` - -### CLI commands - -```php -use Sunrise\Http\Router\Command\RouteListCommand; - -new RouteListCommand($router); -``` - -### Events - -> Available from version 2.13 - -```bash -composer require symfony/event-dispatcher -``` - -```php -use Sunrise\Http\Router\Event\RouteEvent; -use Symfony\Component\EventDispatcher\EventDispatcher; - -$eventDispatcher = new EventDispatcher(); - -$eventDispatcher->addListener(RouteEvent::NAME, function (RouteEvent $event) { - // gets the matched route: - $event->getRoute(); - // gets the current request: - $event->getRequest(); - // overrides the current request: - $event->setRequest(ServerRequestInterface $request); -}); - -$router->setEventDispatcher($eventDispatcher); -``` - ---- - -## Test run - -```bash -composer test -``` - -## Useful links - -* https://www.php-fig.org/psr/psr-7/ -* https://www.php-fig.org/psr/psr-15/ -* https://github.com/sunrise-php/awesome-skeleton -* https://github.com/middlewares +# Very soon... diff --git a/benchmarks/.gitkeep b/benchmarks/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/composer.json b/composer.json index 304c6856..4bffb653 100644 --- a/composer.json +++ b/composer.json @@ -1,75 +1,78 @@ { - "name": "sunrise/http-router", - "homepage": "https://github.com/sunrise-php/http-router", - "description": "HTTP router for PHP 7.1+ based on PSR-7 and PSR-15 with support for annotations/attributes and OpenAPI (Swagger) Specification", - "license": "MIT", - "keywords": [ - "fenric", - "sunrise", - "http", - "router", - "request-handler", - "middlewares", - "annotations", - "attributes", - "openapi", - "swagger", - "psr-7", - "psr-15", - "php7", - "php8" - ], - "authors": [ - { - "name": "Anatoly Fenric", - "email": "afenric@gmail.com", - "homepage": "https://github.com/fenric" - } + "name": "sunrise/http-router", + "homepage": "https://github.com/sunrise-php/http-router", + "description": "HTTP router for PHP 8.1+ based on PSR-7 and PSR-15", + "license": "MIT", + "keywords": [ + "fenric", + "sunrise", + "http", + "router", + "openapi", + "swagger", + "psr-7", + "psr-15" + ], + "authors": [ + { + "name": "Anatoly Nekhay", + "email": "afenric@gmail.com", + "homepage": "https://github.com/fenric" + } + ], + "require": { + "php": ">=8.1", + "fig/http-message-util": "^1.1", + "psr/container": "^1.0 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "php-di/php-di": "^7.0", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.10", + "sunrise/http-message": "^3.2", + "sunrise/hydrator": "^3.13", + "symfony/console": "^6.4", + "symfony/validator": "^6.4", + "vimeo/psalm": "^5.26" + }, + "autoload": { + "files": [ + "src/functions/emit.php" ], - "require": { - "php": "^7.1|^8.0", - "fig/http-message-util": "^1.1", - "psr/container": "^1.0", - "psr/http-message": "^1.0", - "psr/http-server-handler": "^1.0", - "psr/http-server-middleware": "^1.0", - "psr/simple-cache": "^1.0" - }, - "require-dev": { - "phpunit/phpunit": "7.5.20|9.5.0", - "sunrise/coding-standard": "1.0.0", - "sunrise/http-factory": "2.0.0", - "doctrine/annotations": "^1.6", - "symfony/console": "^4.4", - "symfony/event-dispatcher": "^4.4" - }, - "autoload": { - "files": [ - "functions/emit.php", - "functions/path_build.php", - "functions/path_match.php", - "functions/path_parse.php", - "functions/path_plain.php", - "functions/path_regex.php" - ], - "psr-4": { - "Sunrise\\Http\\Router\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Sunrise\\Http\\Router\\Tests\\": "tests/" - } - }, - "scripts": { - "test": [ - "phpcs", - "psalm", - "XDEBUG_MODE=coverage phpunit --coverage-text --colors=always" - ], - "build": [ - "phpdoc -d src/ -t phpdoc/", - "XDEBUG_MODE=coverage phpunit --coverage-html coverage/" - ] + "psr-4": { + "Sunrise\\Http\\Router\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Sunrise\\Http\\Router\\Tests\\": "tests/" + } + }, + "scripts": { + "phpcs": "phpcs --colors", + "psalm": "psalm --no-cache", + "phpstan": "phpstan analyse src --level=9 --memory-limit=-1", + "phpunit": "XDEBUG_MODE=coverage phpunit --coverage-text --colors=always", + "test": [ + "@phpcs", + "@psalm", + "@phpstan", + "@phpunit" + ], + "build": [ + "phpdoc -d src/ -t phpdoc/", + "XDEBUG_MODE=coverage phpunit --coverage-html coverage/" + ] + }, + "config": { + "sort-packages": true + } } diff --git a/functions/path_build.php b/functions/path_build.php deleted file mode 100644 index d7557b9a..00000000 --- a/functions/path_build.php +++ /dev/null @@ -1,84 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Sunrise\Http\Router\Exception\InvalidAttributeValueException; -use Sunrise\Http\Router\Exception\MissingAttributeValueException; - -/** - * Import functions - */ -use function preg_match; -use function sprintf; -use function str_replace; - -/** - * Builds the given path using the given attributes - * - * If strict mode is enabled, each attribute value will be validated. - * - * @param string $path - * @param array $attributes - * @param bool $strict - * - * @return string - * - * @throws InvalidAttributeValueException - * @throws MissingAttributeValueException - */ -function path_build(string $path, array $attributes = [], bool $strict = false) : string -{ - $result = $path; - $matches = path_parse($path); - - foreach ($matches as $match) { - // handle not required attributes... - if (!isset($attributes[$match['name']])) { - if (!$match['isOptional']) { - $errmsg = '[%s] build error: no value given for the attribute "%s".'; - - throw new MissingAttributeValueException(sprintf($errmsg, $path, $match['name']), [ - 'path' => $path, - 'match' => $match, - ]); - } - - $result = str_replace($match['withParentheses'], '', $result); - - continue; - } - - $replacement = (string) $attributes[$match['name']]; - - // validate the given attributes values... - if ($strict && isset($match['pattern'])) { - if (!preg_match('#^' . $match['pattern'] . '$#u', $replacement)) { - $errmsg = '[%s] build error: the given value for the attribute "%s" does not match its pattern.'; - - throw new InvalidAttributeValueException(sprintf($errmsg, $path, $match['name']), [ - 'path' => $path, - 'value' => $replacement, - 'match' => $match, - ]); - } - } - - $result = str_replace($match['raw'], $replacement, $result); - } - - $result = str_replace(['(', ')'], '', $result); - - return $result; -} diff --git a/functions/path_match.php b/functions/path_match.php deleted file mode 100644 index 2142c96c..00000000 --- a/functions/path_match.php +++ /dev/null @@ -1,44 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import functions - */ -use function is_int; -use function preg_match; - -/** - * Compares the given path and the given subject... - * - * @param string $path - * @param string $subject - * - * @return bool - */ -function path_match(string $path, string $subject, &$attributes = null) : bool -{ - $attributes = []; - - $regex = path_regex($path); - if (!preg_match($regex, $subject, $matches)) { - return false; - } - - foreach ($matches as $key => $value) { - if (!is_int($key) && '' !== $value) { - $attributes[$key] = $value; - } - } - - return true; -} diff --git a/functions/path_parse.php b/functions/path_parse.php deleted file mode 100644 index b95be62b..00000000 --- a/functions/path_parse.php +++ /dev/null @@ -1,286 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Sunrise\Http\Router\Exception\InvalidPathException; - -/** - * Import functions - */ -use function sprintf; - -/** - * Allowed characters for the first character of the subpattern name - * - * @var array - */ -const CHARACTER_TABLE_FOR_FIRST_CHARACTER_OF_SUBPATTERN_NAME = [ - 'A' => 1, 'B' => 1, 'C' => 1, 'D' => 1, 'E' => 1, 'F' => 1, 'G' => 1, 'H' => 1, 'I' => 1, 'J' => 1, - 'K' => 1, 'L' => 1, 'M' => 1, 'N' => 1, 'O' => 1, 'P' => 1, 'Q' => 1, 'R' => 1, 'S' => 1, 'T' => 1, - 'U' => 1, 'V' => 1, 'W' => 1, 'X' => 1, 'Y' => 1, 'Z' => 1, - 'a' => 1, 'b' => 1, 'c' => 1, 'd' => 1, 'e' => 1, 'f' => 1, 'g' => 1, 'h' => 1, 'i' => 1, 'j' => 1, - 'k' => 1, 'l' => 1, 'm' => 1, 'n' => 1, 'o' => 1, 'p' => 1, 'q' => 1, 'r' => 1, 's' => 1, 't' => 1, - 'u' => 1, 'v' => 1, 'w' => 1, 'x' => 1, 'y' => 1, 'z' => 1, - '_' => 1, -]; - -/** - * Allowed characters for the subpattern name - * - * @var array - */ -const CHARACTER_TABLE_FOR_SUBPATTERN_NAME = [ - '0' => 1, '1' => 1, '2' => 1, '3' => 1, '4' => 1, '5' => 1, '6' => 1, '7' => 1, '8' => 1, '9' => 1, - 'A' => 1, 'B' => 1, 'C' => 1, 'D' => 1, 'E' => 1, 'F' => 1, 'G' => 1, 'H' => 1, 'I' => 1, 'J' => 1, - 'K' => 1, 'L' => 1, 'M' => 1, 'N' => 1, 'O' => 1, 'P' => 1, 'Q' => 1, 'R' => 1, 'S' => 1, 'T' => 1, - 'U' => 1, 'V' => 1, 'W' => 1, 'X' => 1, 'Y' => 1, 'Z' => 1, - 'a' => 1, 'b' => 1, 'c' => 1, 'd' => 1, 'e' => 1, 'f' => 1, 'g' => 1, 'h' => 1, 'i' => 1, 'j' => 1, - 'k' => 1, 'l' => 1, 'm' => 1, 'n' => 1, 'o' => 1, 'p' => 1, 'q' => 1, 'r' => 1, 's' => 1, 't' => 1, - 'u' => 1, 'v' => 1, 'w' => 1, 'x' => 1, 'y' => 1, 'z' => 1, - '_' => 1, -]; - -/** - * Parses the given path - * - * @param string $path - * - * @return array - * - * @throws InvalidPathException If the given path syntax isn't valid. - */ -function path_parse(string $path) : array -{ - // This will be useful for a long-running application, - // for example if you use the RoadRunner server... - static $cache = []; - - if (isset($cache[$path])) { - return $cache[$path]; - } - - $attributes = []; - $attributeIndex = -1; - $attributePrototype = [ - 'raw' => null, - 'withParentheses' => null, - 'name' => null, - 'pattern' => null, - 'isOptional' => false, - 'startPosition' => -1, - 'endPosition' => -1, - ]; - - $cursorPosition = -1; - $cursorInParentheses = false; - $cursorInAttribute = false; - $cursorInAttributeName = false; - $cursorInPattern = false; - - $parenthesesBusy = false; - $parenthesesLeft = null; - $parenthesesRight = null; - - while (true) { - $cursorPosition++; - - if (!isset($path[$cursorPosition])) { - break; - } - - $char = $path[$cursorPosition]; - - if ('(' === $char && !$cursorInAttribute) { - if ($cursorInParentheses) { - throw new InvalidPathException( - sprintf('[%s:%d] parentheses inside parentheses are not allowed.', $path, $cursorPosition) - ); - } - - $cursorInParentheses = true; - - continue; - } - - if ('{' === $char && !$cursorInPattern) { - if ($cursorInAttribute) { - throw new InvalidPathException( - sprintf('[%s:%d] braces inside attributes are not allowed.', $path, $cursorPosition) - ); - } - - if ($parenthesesBusy) { - throw new InvalidPathException( - sprintf('[%s:%d] multiple attributes inside parentheses are not allowed.', $path, $cursorPosition) - ); - } - - if ($cursorInParentheses) { - $parenthesesBusy = true; - } - - $cursorInAttribute = true; - $cursorInAttributeName = true; - - $attributeIndex++; - $attributes[$attributeIndex] = $attributePrototype; - $attributes[$attributeIndex]['raw'] = $char; - $attributes[$attributeIndex]['isOptional'] = $cursorInParentheses; - $attributes[$attributeIndex]['startPosition'] = $cursorPosition; - - continue; - } - - if ('<' === $char && $cursorInAttribute) { - if ($cursorInPattern) { - throw new InvalidPathException( - sprintf('[%s:%d] the char "<" inside patterns is not allowed.', $path, $cursorPosition) - ); - } - - $cursorInPattern = true; - $cursorInAttributeName = false; - - $attributes[$attributeIndex]['raw'] .= $char; - - continue; - } - - if ('>' === $char && $cursorInAttribute) { - if (!$cursorInPattern) { - throw new InvalidPathException( - sprintf('[%s:%d] at position %2$d an extra char ">" was found.', $path, $cursorPosition) - ); - } - - if (null === $attributes[$attributeIndex]['pattern']) { - throw new InvalidPathException( - sprintf('[%s:%d] an attribute pattern is empty.', $path, $cursorPosition) - ); - } - - if (isset(Router::$patterns[$attributes[$attributeIndex]['pattern']])) { - $attributes[$attributeIndex]['pattern'] = Router::$patterns[$attributes[$attributeIndex]['pattern']]; - } - - $cursorInPattern = false; - - $attributes[$attributeIndex]['raw'] .= $char; - - continue; - } - - if ('}' === $char && !$cursorInPattern) { - if (!$cursorInAttribute) { - throw new InvalidPathException( - sprintf('[%s:%d] at position %2$d an extra closing brace was found.', $path, $cursorPosition) - ); - } - - if (null === $attributes[$attributeIndex]['name']) { - throw new InvalidPathException( - sprintf('[%s:%d] an attribute name is empty.', $path, $cursorPosition) - ); - } - - $cursorInAttribute = false; - $cursorInAttributeName = false; - - $attributes[$attributeIndex]['raw'] .= $char; - $attributes[$attributeIndex]['endPosition'] = $cursorPosition; - - continue; - } - - if (')' === $char && !$cursorInAttribute) { - if (!$cursorInParentheses) { - throw new InvalidPathException( - sprintf('[%s:%d] at position %2$d an extra closing parenthesis was found.', $path, $cursorPosition) - ); - } - - if ($parenthesesBusy) { - $attributes[$attributeIndex]['withParentheses'] = '(' . $parenthesesLeft; - $attributes[$attributeIndex]['withParentheses'] .= $attributes[$attributeIndex]['raw']; - $attributes[$attributeIndex]['withParentheses'] .= $parenthesesRight . ')'; - } - - $cursorInParentheses = false; - $parenthesesBusy = false; - $parenthesesLeft = null; - $parenthesesRight = null; - - continue; - } - - if ($cursorInParentheses && !$cursorInAttribute && !$parenthesesBusy) { - $parenthesesLeft .= $char; - } - - if ($cursorInParentheses && !$cursorInAttribute && $parenthesesBusy) { - $parenthesesRight .= $char; - } - - if ($cursorInAttribute) { - $attributes[$attributeIndex]['raw'] .= $char; - } - - if ($cursorInAttributeName) { - if (null === $attributes[$attributeIndex]['name']) { - if (!isset(CHARACTER_TABLE_FOR_FIRST_CHARACTER_OF_SUBPATTERN_NAME[$char])) { - throw new InvalidPathException( - sprintf('[%s:%d] an attribute name must begin with "A-Za-z_".', $path, $cursorPosition) - ); - } - } - - if (null !== $attributes[$attributeIndex]['name']) { - if (!isset(CHARACTER_TABLE_FOR_SUBPATTERN_NAME[$char])) { - throw new InvalidPathException( - sprintf('[%s:%d] an attribute name must contain only "0-9A-Za-z_".', $path, $cursorPosition) - ); - } - } - - $attributes[$attributeIndex]['name'] .= $char; - } - - if ($cursorInPattern) { - if ('#' === $char) { - throw new InvalidPathException( - sprintf('[%s:%d] unallowed character "#" in an attribute pattern.', $path, $cursorPosition) - ); - } - - $attributes[$attributeIndex]['pattern'] .= $char; - } - } - - if ($cursorInParentheses) { - throw new InvalidPathException( - sprintf('[%s] the route path contains non-closed parentheses.', $path) - ); - } - - if ($cursorInAttribute) { - throw new InvalidPathException( - sprintf('[%s] the route path contains non-closed attribute.', $path) - ); - } - - $cache[$path] = $attributes; - - return $attributes; -} diff --git a/functions/path_plain.php b/functions/path_plain.php deleted file mode 100644 index d8638eec..00000000 --- a/functions/path_plain.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import functions - */ -use function str_replace; - -/** - * Simplifies the given path - * - * @param string $path - * - * @return string - */ -function path_plain(string $path) : string -{ - $attrs = path_parse($path); - - foreach ($attrs as $attr) { - $path = str_replace($attr['raw'], '{' . $attr['name'] . '}', $path); - } - - return str_replace(['(', ')'], '', $path); -} diff --git a/functions/path_regex.php b/functions/path_regex.php deleted file mode 100644 index 40162180..00000000 --- a/functions/path_regex.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import functions - */ -use function addcslashes; -use function str_replace; - -/** - * Converts the given path to Regular Expression - * - * @param string $path - * - * @return string - */ -function path_regex(string $path) : string -{ - // This will be useful for a long-running application, - // for example if you use the RoadRunner server... - static $cache = []; - - if (isset($cache[$path])) { - return $cache[$path]; - } - - $matches = path_parse($path); - - foreach ($matches as $match) { - $path = str_replace($match['raw'], '{' . $match['name'] . '}', $path); - } - - $path = addcslashes($path, '#$*+-.?[\]^|'); - $path = str_replace(['(', ')'], ['(?:', ')?'], $path); - - foreach ($matches as $match) { - $pattern = $match['pattern'] ?? '[^/]+'; - $subpattern = '(?<' . $match['name'] . '>' . $pattern . ')'; - - $path = str_replace('{' . $match['name'] . '}', $subpattern, $path); - } - - $cache[$path] = '#^' . $path . '$#uD'; - - return $cache[$path]; -} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 862c7d44..ae1272cf 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,9 +1,12 @@ - + + + + + tests/* + - benchmarks - functions src tests diff --git a/psalm.xml.dist b/psalm.xml.dist index 3becd9ba..43a0a144 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -1,9 +1,10 @@ @@ -11,4 +12,9 @@ + + + + + diff --git a/resources/definitions/router.php b/resources/definitions/router.php new file mode 100644 index 00000000..9806e95d --- /dev/null +++ b/resources/definitions/router.php @@ -0,0 +1,80 @@ + [], + 'router.middlewares' => [], + 'router.route_middlewares' => [], + 'router.parameter_resolvers' => [], + 'router.response_resolvers' => [], + 'router.event_dispatcher' => null, + + ParameterResolverChainInterface::class => create(ParameterResolverChain::class) + ->constructor( + get('router.parameter_resolvers'), + ), + + ResponseResolverChainInterface::class => create(ResponseResolverChain::class) + ->constructor( + get('router.response_resolvers'), + ), + + ClassResolverInterface::class => create(ClassResolver::class) + ->constructor( + get(ParameterResolverChainInterface::class), + get(ContainerInterface::class), + ), + + MiddlewareResolverInterface::class => create(MiddlewareResolver::class) + ->constructor( + get(ClassResolverInterface::class), + get(ParameterResolverChainInterface::class), + get(ResponseResolverChainInterface::class), + ), + + RequestHandlerResolverInterface::class => create(RequestHandlerResolver::class) + ->constructor( + get(ClassResolverInterface::class), + get(ParameterResolverChainInterface::class), + get(ResponseResolverChainInterface::class), + ), + + ReferenceResolverInterface::class => create(ReferenceResolver::class) + ->constructor( + get(MiddlewareResolverInterface::class), + get(RequestHandlerResolverInterface::class), + ), + + RequestHandlerReflectorInterface::class => create(RequestHandlerReflector::class), + + RouterInterface::class => create(Router::class) + ->constructor( + loaders: get('router.loaders'), + middlewares: get('router.middlewares'), + routeMiddlewares: get('router.route_middlewares'), + referenceResolver: get(ReferenceResolverInterface::class), + eventDispatcher: get('router.event_dispatcher'), + ), +]; diff --git a/resources/translations/ru.php b/resources/translations/ru.php new file mode 100644 index 00000000..48997d72 --- /dev/null +++ b/resources/translations/ru.php @@ -0,0 +1,25 @@ + 'URI запроса невалиден и не может быть принят сервером.', + ErrorMessage::RESOURCE_NOT_FOUND => 'Запрашиваемый ресурс не найден для данного URI.', + ErrorMessage::METHOD_NOT_ALLOWED => 'Запрашиваемый метод не разрешен для данного ресурса; Проверьте заголовок ответа "Allow" на разрешенные методы.', + ErrorMessage::MISSING_CONTENT_TYPE => 'Заголовок запроса Content-Type должен быть предоставлен и не может быть пустым; Проверьте заголовок ответа "Accept" на поддерживаемые типы медиа.', + ErrorMessage::UNSUPPORTED_MEDIA_TYPE => 'Тип медиа {{ media_type }} не поддерживается; Проверьте заголовок ответа "Accept" на поддерживаемые типы медиа.', + ErrorMessage::INVALID_VARIABLE => 'Значение переменной {{{ variable_name }}} в URI запроса {{ route_uri }} невалидно.', + ErrorMessage::INVALID_QUERY => 'Параметры запроса невалидны.', + ErrorMessage::MISSING_HEADER => 'Заголовок запроса {{ header_name }} должен быть предоставлен.', + ErrorMessage::INVALID_HEADER => 'Заголовок запроса {{ header_name }} невалиден.', + ErrorMessage::MISSING_COOKIE => 'Отсутствует cookie {{ cookie_name }}.', + ErrorMessage::INVALID_COOKIE => 'Cookie {{ cookie_name }} невалидно.', + ErrorMessage::INVALID_BODY => 'Тело запроса невалидно.', + ErrorMessage::EMPTY_JSON_PAYLOAD => 'JSON payload не может быть пустым.', + ErrorMessage::INVALID_JSON_PAYLOAD => 'JSON payload невалидно.', + ErrorMessage::INVALID_JSON_PAYLOAD_FORMAT => 'JSON payload должен быть в формате массива или объекта.', + // phpcs:enable Generic.Files.LineLength.TooLong +]; diff --git a/resources/translations/sr.php b/resources/translations/sr.php new file mode 100644 index 00000000..468e8fb8 --- /dev/null +++ b/resources/translations/sr.php @@ -0,0 +1,25 @@ + 'URI zahtev je nevažeći i ne može biti prihvaćen od strane servera.', + ErrorMessage::RESOURCE_NOT_FOUND => 'Zatraženi resurs nije pronađen za ovaj URI.', + ErrorMessage::METHOD_NOT_ALLOWED => 'Zatražena metoda nije dozvoljena za ovaj resurs; Proverite zaglavlje odgovora "Allow" za dozvoljene metode.', + ErrorMessage::MISSING_CONTENT_TYPE => 'Zaglavlje zahteva Content-Type mora biti navedeno i ne može biti prazno; Proverite zaglavlje odgovora "Accept" za podržane tipove medija.', + ErrorMessage::UNSUPPORTED_MEDIA_TYPE => 'Tip medija {{ media_type }} nije podržan; Proverite zaglavlje odgovora "Accept" za podržane tipove medija.', + ErrorMessage::INVALID_VARIABLE => 'Vrednost promenljive {{{ variable_name }}} u URI zahtevu {{ route_uri }} nije validna.', + ErrorMessage::INVALID_QUERY => 'Parametri upita zahteva nisu validni.', + ErrorMessage::MISSING_HEADER => 'Zaglavlje zahteva {{ header_name }} mora biti navedeno.', + ErrorMessage::INVALID_HEADER => 'Zaglavlje zahteva {{ header_name }} nije validno.', + ErrorMessage::MISSING_COOKIE => 'Kolačić {{ cookie_name }} nedostaje.', + ErrorMessage::INVALID_COOKIE => 'Kolačić {{ cookie_name }} nije validan.', + ErrorMessage::INVALID_BODY => 'Telo zahteva nije validno.', + ErrorMessage::EMPTY_JSON_PAYLOAD => 'JSON sadržaj ne može biti prazan.', + ErrorMessage::INVALID_JSON_PAYLOAD => 'JSON sadržaj nije validan.', + ErrorMessage::INVALID_JSON_PAYLOAD_FORMAT => 'JSON sadržaj mora biti u formatu niza ili objekta.', + // phpcs:enable Generic.Files.LineLength.TooLong +]; diff --git a/src/Annotation/ApiOperation.php b/src/Annotation/ApiOperation.php new file mode 100644 index 00000000..1585b19d --- /dev/null +++ b/src/Annotation/ApiOperation.php @@ -0,0 +1,41 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +class ApiOperation extends Route +{ + /** + * @param string|array $method + */ + public function __construct( + string $name, + string $path = '', + string|array $method = [], + array $fields = [], + ) { + parent::__construct( + name: $name, + path: $path, + methods: (array) $method, + isApiOperation: true, + apiOperationFields: $fields, + ); + } +} diff --git a/src/Annotation/Constraint.php b/src/Annotation/Constraint.php new file mode 100644 index 00000000..2b17777c --- /dev/null +++ b/src/Annotation/Constraint.php @@ -0,0 +1,34 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +// phpcs:ignore Generic.Files.LineLength.TooLong +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] +final class Constraint +{ + /** + * @var array + */ + public readonly array $values; + + public function __construct(mixed ...$values) + { + $this->values = $values; + } +} diff --git a/src/Annotation/Consumes.php b/src/Annotation/Consumes.php new file mode 100644 index 00000000..159088ef --- /dev/null +++ b/src/Annotation/Consumes.php @@ -0,0 +1,34 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Consumes +{ + /** + * @var array + */ + public readonly array $values; + + public function __construct(MediaTypeInterface ...$values) + { + $this->values = $values; + } +} diff --git a/src/Annotation/ConsumesJson.php b/src/Annotation/ConsumesJson.php new file mode 100644 index 00000000..3d289ae9 --- /dev/null +++ b/src/Annotation/ConsumesJson.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\MediaType; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class ConsumesJson extends Consumes +{ + public function __construct() + { + parent::__construct(MediaType::JSON); + } +} diff --git a/src/Annotation/ConsumesXml.php b/src/Annotation/ConsumesXml.php new file mode 100644 index 00000000..e09458f9 --- /dev/null +++ b/src/Annotation/ConsumesXml.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\MediaType; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class ConsumesXml extends Consumes +{ + public function __construct() + { + parent::__construct(MediaType::XML); + } +} diff --git a/src/Annotation/DefaultAttribute.php b/src/Annotation/DefaultAttribute.php new file mode 100644 index 00000000..b55c0fe1 --- /dev/null +++ b/src/Annotation/DefaultAttribute.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class DefaultAttribute +{ + public function __construct( + public readonly string $name, + public readonly mixed $value, + ) { + } +} diff --git a/src/Annotation/DeleteApiOperation.php b/src/Annotation/DeleteApiOperation.php new file mode 100644 index 00000000..e9be79c5 --- /dev/null +++ b/src/Annotation/DeleteApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class DeleteApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_DELETE, $fields); + } +} diff --git a/src/Annotation/DeleteMethod.php b/src/Annotation/DeleteMethod.php new file mode 100644 index 00000000..d9918cc2 --- /dev/null +++ b/src/Annotation/DeleteMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class DeleteMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_DELETE); + } +} diff --git a/src/Annotation/Deprecated.php b/src/Annotation/Deprecated.php new file mode 100644 index 00000000..0510e025 --- /dev/null +++ b/src/Annotation/Deprecated.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class Deprecated +{ +} diff --git a/src/Annotation/Description.php b/src/Annotation/Description.php new file mode 100644 index 00000000..57f60491 --- /dev/null +++ b/src/Annotation/Description.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class Description +{ + public function __construct( + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/EmptyResponse.php b/src/Annotation/EmptyResponse.php new file mode 100644 index 00000000..20fed114 --- /dev/null +++ b/src/Annotation/EmptyResponse.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)] +final class EmptyResponse +{ +} diff --git a/src/Annotation/GetApiOperation.php b/src/Annotation/GetApiOperation.php new file mode 100644 index 00000000..968f235f --- /dev/null +++ b/src/Annotation/GetApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class GetApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_GET, $fields); + } +} diff --git a/src/Annotation/GetMethod.php b/src/Annotation/GetMethod.php new file mode 100644 index 00000000..c8c8754c --- /dev/null +++ b/src/Annotation/GetMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class GetMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_GET); + } +} diff --git a/src/Annotation/HeadApiOperation.php b/src/Annotation/HeadApiOperation.php new file mode 100644 index 00000000..c7182a5e --- /dev/null +++ b/src/Annotation/HeadApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class HeadApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_HEAD, $fields); + } +} diff --git a/src/Annotation/HeadMethod.php b/src/Annotation/HeadMethod.php new file mode 100644 index 00000000..4ab35b09 --- /dev/null +++ b/src/Annotation/HeadMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class HeadMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_HEAD); + } +} diff --git a/src/Annotation/Host.php b/src/Annotation/Host.php deleted file mode 100644 index 209c4f5c..00000000 --- a/src/Annotation/Host.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Annotation; - -/** - * Import classes - */ -use Attribute; - -/** - * @Annotation - * - * @Target({"CLASS", "METHOD"}) - * - * @NamedArgumentConstructor - * - * @Attributes({ - * @Attribute("value", type="string", required=true), - * }) - * - * @since 2.11.0 - */ -#[Attribute(Attribute::TARGET_CLASS|Attribute::TARGET_METHOD)] -final class Host -{ - - /** - * The attribute value - * - * @var string - */ - public $value; - - /** - * Constructor of the class - * - * @param string $value - */ - public function __construct(string $value) - { - $this->value = $value; - } -} diff --git a/src/Annotation/JsonResponse.php b/src/Annotation/JsonResponse.php new file mode 100644 index 00000000..d8cbcd7a --- /dev/null +++ b/src/Annotation/JsonResponse.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\MediaType; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)] +final class JsonResponse extends SerializableResponse +{ + public function __construct( + public readonly ?int $encodingFlags = null, + public readonly ?int $encodingDepth = null, + ) { + } + + /** + * @inheritDoc + */ + public function getMediaTypes(): array + { + return [MediaType::JSON]; + } +} diff --git a/src/Annotation/Method.php b/src/Annotation/Method.php new file mode 100644 index 00000000..e328197b --- /dev/null +++ b/src/Annotation/Method.php @@ -0,0 +1,36 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * Pay attention to the {@see RequestMethodInterface} dictionary. + * + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Method implements RequestMethodInterface +{ + /** + * @var array + */ + public readonly array $values; + + public function __construct(string ...$values) + { + $this->values = $values; + } +} diff --git a/src/Annotation/Middleware.php b/src/Annotation/Middleware.php index eba445c1..b63d2e6d 100644 --- a/src/Annotation/Middleware.php +++ b/src/Annotation/Middleware.php @@ -1,52 +1,33 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Annotation; -/** - * Import classes - */ use Attribute; /** - * @Annotation - * - * @Target({"CLASS", "METHOD"}) - * - * @NamedArgumentConstructor - * - * @Attributes({ - * @Attribute("value", type="string", required=true), - * }) - * * @since 2.11.0 */ -#[Attribute(Attribute::TARGET_CLASS|Attribute::TARGET_METHOD|Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class Middleware { - /** - * The attribute value - * - * @var string + * @var array */ - public $value; + public readonly array $values; - /** - * Constructor of the class - * - * @param string $value - */ - public function __construct(string $value) + public function __construct(mixed ...$values) { - $this->value = $value; + $this->values = $values; } } diff --git a/src/Annotation/NamePrefix.php b/src/Annotation/NamePrefix.php new file mode 100644 index 00000000..1614babb --- /dev/null +++ b/src/Annotation/NamePrefix.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class NamePrefix +{ + public function __construct( + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/OptionsApiOperation.php b/src/Annotation/OptionsApiOperation.php new file mode 100644 index 00000000..3387888c --- /dev/null +++ b/src/Annotation/OptionsApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class OptionsApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_OPTIONS, $fields); + } +} diff --git a/src/Annotation/OptionsMethod.php b/src/Annotation/OptionsMethod.php new file mode 100644 index 00000000..a9cc090a --- /dev/null +++ b/src/Annotation/OptionsMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class OptionsMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_OPTIONS); + } +} diff --git a/src/Annotation/PatchApiOperation.php b/src/Annotation/PatchApiOperation.php new file mode 100644 index 00000000..259211aa --- /dev/null +++ b/src/Annotation/PatchApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class PatchApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_PATCH, $fields); + } +} diff --git a/src/Annotation/PatchMethod.php b/src/Annotation/PatchMethod.php new file mode 100644 index 00000000..23858358 --- /dev/null +++ b/src/Annotation/PatchMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class PatchMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_PATCH); + } +} diff --git a/src/Annotation/Path.php b/src/Annotation/Path.php new file mode 100644 index 00000000..a9e6f7bd --- /dev/null +++ b/src/Annotation/Path.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class Path +{ + public function __construct( + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/PathPostfix.php b/src/Annotation/PathPostfix.php new file mode 100644 index 00000000..8b276933 --- /dev/null +++ b/src/Annotation/PathPostfix.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 2.11.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class PathPostfix +{ + public function __construct( + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/PathPrefix.php b/src/Annotation/PathPrefix.php new file mode 100644 index 00000000..abe65754 --- /dev/null +++ b/src/Annotation/PathPrefix.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 2.11.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class PathPrefix +{ + public function __construct( + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/Pattern.php b/src/Annotation/Pattern.php new file mode 100644 index 00000000..1f15f19d --- /dev/null +++ b/src/Annotation/Pattern.php @@ -0,0 +1,32 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\VariablePattern; + +/** + * Pay attention to the {@see VariablePattern} dictionary. + * + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Pattern +{ + public function __construct( + public readonly string $variableName, + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/PostApiOperation.php b/src/Annotation/PostApiOperation.php new file mode 100644 index 00000000..4ae3fc64 --- /dev/null +++ b/src/Annotation/PostApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class PostApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_POST, $fields); + } +} diff --git a/src/Annotation/PostMethod.php b/src/Annotation/PostMethod.php new file mode 100644 index 00000000..ad439e12 --- /dev/null +++ b/src/Annotation/PostMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class PostMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_POST); + } +} diff --git a/src/Annotation/Postfix.php b/src/Annotation/Postfix.php deleted file mode 100644 index 53f7ab82..00000000 --- a/src/Annotation/Postfix.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Annotation; - -/** - * Import classes - */ -use Attribute; - -/** - * @Annotation - * - * @Target({"CLASS", "METHOD"}) - * - * @NamedArgumentConstructor - * - * @Attributes({ - * @Attribute("value", type="string", required=true), - * }) - * - * @since 2.11.0 - */ -#[Attribute(Attribute::TARGET_CLASS|Attribute::TARGET_METHOD)] -final class Postfix -{ - - /** - * The attribute value - * - * @var string - */ - public $value; - - /** - * Constructor of the class - * - * @param string $value - */ - public function __construct(string $value) - { - $this->value = $value; - } -} diff --git a/src/Annotation/Prefix.php b/src/Annotation/Prefix.php deleted file mode 100644 index 84fdfc5c..00000000 --- a/src/Annotation/Prefix.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Annotation; - -/** - * Import classes - */ -use Attribute; - -/** - * @Annotation - * - * @Target({"CLASS", "METHOD"}) - * - * @NamedArgumentConstructor - * - * @Attributes({ - * @Attribute("value", type="string", required=true), - * }) - * - * @since 2.11.0 - */ -#[Attribute(Attribute::TARGET_CLASS|Attribute::TARGET_METHOD)] -final class Prefix -{ - - /** - * The attribute value - * - * @var string - */ - public $value; - - /** - * Constructor of the class - * - * @param string $value - */ - public function __construct(string $value) - { - $this->value = $value; - } -} diff --git a/src/Annotation/Priority.php b/src/Annotation/Priority.php new file mode 100644 index 00000000..690db471 --- /dev/null +++ b/src/Annotation/Priority.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class Priority +{ + public function __construct( + public readonly int $value, + ) { + } +} diff --git a/src/Annotation/Produces.php b/src/Annotation/Produces.php new file mode 100644 index 00000000..b911b801 --- /dev/null +++ b/src/Annotation/Produces.php @@ -0,0 +1,34 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Produces +{ + /** + * @var array + */ + public readonly array $values; + + public function __construct(MediaTypeInterface ...$values) + { + $this->values = $values; + } +} diff --git a/src/Annotation/ProducesJson.php b/src/Annotation/ProducesJson.php new file mode 100644 index 00000000..324413bf --- /dev/null +++ b/src/Annotation/ProducesJson.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\MediaType; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class ProducesJson extends Produces +{ + public function __construct() + { + parent::__construct(MediaType::JSON); + } +} diff --git a/src/Annotation/ProducesXml.php b/src/Annotation/ProducesXml.php new file mode 100644 index 00000000..deca49dd --- /dev/null +++ b/src/Annotation/ProducesXml.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\MediaType; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class ProducesXml extends Produces +{ + public function __construct() + { + parent::__construct(MediaType::XML); + } +} diff --git a/src/Annotation/PurgeApiOperation.php b/src/Annotation/PurgeApiOperation.php new file mode 100644 index 00000000..e1c63b6b --- /dev/null +++ b/src/Annotation/PurgeApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class PurgeApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_PURGE, $fields); + } +} diff --git a/src/Annotation/PurgeMethod.php b/src/Annotation/PurgeMethod.php new file mode 100644 index 00000000..59cf127b --- /dev/null +++ b/src/Annotation/PurgeMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class PurgeMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_PURGE); + } +} diff --git a/src/Annotation/PutApiOperation.php b/src/Annotation/PutApiOperation.php new file mode 100644 index 00000000..5e0ef048 --- /dev/null +++ b/src/Annotation/PutApiOperation.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final class PutApiOperation extends ApiOperation +{ + public function __construct(string $name, string $path = '', array $fields = []) + { + parent::__construct($name, $path, self::METHOD_PUT, $fields); + } +} diff --git a/src/Annotation/PutMethod.php b/src/Annotation/PutMethod.php new file mode 100644 index 00000000..abb1a392 --- /dev/null +++ b/src/Annotation/PutMethod.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\RequestMethodInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class PutMethod extends Method +{ + public function __construct() + { + parent::__construct(RequestMethodInterface::METHOD_PUT); + } +} diff --git a/src/Annotation/RequestBody.php b/src/Annotation/RequestBody.php new file mode 100644 index 00000000..f1266408 --- /dev/null +++ b/src/Annotation/RequestBody.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final class RequestBody +{ + public function __construct( + public readonly ?int $errorStatusCode = null, + public readonly ?string $errorMessage = null, + public readonly ?bool $validationEnabled = null, + ) { + } +} diff --git a/src/Annotation/RequestCookie.php b/src/Annotation/RequestCookie.php new file mode 100644 index 00000000..c3cba4aa --- /dev/null +++ b/src/Annotation/RequestCookie.php @@ -0,0 +1,31 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final class RequestCookie +{ + public function __construct( + public readonly string $name, + public readonly ?int $errorStatusCode = null, + public readonly ?string $errorMessage = null, + public readonly ?bool $validationEnabled = null, + ) { + } +} diff --git a/src/Annotation/RequestHeader.php b/src/Annotation/RequestHeader.php new file mode 100644 index 00000000..1fcb6df1 --- /dev/null +++ b/src/Annotation/RequestHeader.php @@ -0,0 +1,31 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final class RequestHeader +{ + public function __construct( + public readonly string $name, + public readonly ?int $errorStatusCode = null, + public readonly ?string $errorMessage = null, + public readonly ?bool $validationEnabled = null, + ) { + } +} diff --git a/src/Annotation/RequestQuery.php b/src/Annotation/RequestQuery.php new file mode 100644 index 00000000..e9995486 --- /dev/null +++ b/src/Annotation/RequestQuery.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final class RequestQuery +{ + public function __construct( + public readonly ?int $errorStatusCode = null, + public readonly ?string $errorMessage = null, + public readonly ?bool $validationEnabled = null, + ) { + } +} diff --git a/src/Annotation/RequestVariable.php b/src/Annotation/RequestVariable.php new file mode 100644 index 00000000..0310a90c --- /dev/null +++ b/src/Annotation/RequestVariable.php @@ -0,0 +1,31 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +final class RequestVariable +{ + public function __construct( + public readonly ?string $name = null, + public readonly ?int $errorStatusCode = null, + public readonly ?string $errorMessage = null, + public readonly ?bool $validationEnabled = null, + ) { + } +} diff --git a/src/Annotation/ResponseHeader.php b/src/Annotation/ResponseHeader.php new file mode 100644 index 00000000..24f4b4b4 --- /dev/null +++ b/src/Annotation/ResponseHeader.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION | Attribute::IS_REPEATABLE)] +final class ResponseHeader +{ + public function __construct( + public readonly string $name, + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/ResponseStatus.php b/src/Annotation/ResponseStatus.php new file mode 100644 index 00000000..00d3cbf8 --- /dev/null +++ b/src/Annotation/ResponseStatus.php @@ -0,0 +1,32 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Fig\Http\Message\StatusCodeInterface; + +/** + * Pay attention to the {@see StatusCodeInterface} dictionary. + * + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)] +final class ResponseStatus implements StatusCodeInterface +{ + public function __construct( + public readonly int $code, + public readonly string $phrase = '', + ) { + } +} diff --git a/src/Annotation/Route.php b/src/Annotation/Route.php index 1b7d5c7b..114818af 100644 --- a/src/Annotation/Route.php +++ b/src/Annotation/Route.php @@ -1,172 +1,63 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Annotation; -/** - * Import classes - */ use Attribute; +use Fig\Http\Message\RequestMethodInterface; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; /** - * @Annotation - * - * @Target({"CLASS", "METHOD"}) - * - * @NamedArgumentConstructor - * - * @Attributes({ - * @Attribute("name", type="string", required=true), - * @Attribute("host", type="string"), - * @Attribute("path", type="string", required=true), - * @Attribute("method", type="string"), - * @Attribute("methods", type="array"), - * @Attribute("middlewares", type="array"), - * @Attribute("attributes", type="array"), - * @Attribute("summary", type="string"), - * @Attribute("description", type="string"), - * @Attribute("tags", type="array"), - * @Attribute("priority", type="integer"), - * }) + * @since 2.0.0 */ -#[Attribute(Attribute::TARGET_CLASS|Attribute::TARGET_METHOD)] -final class Route +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +class Route implements RequestMethodInterface { + public mixed $holder = null; - /** - * The descriptor holder - * - * @var mixed - * - * @internal - */ - public $holder; - - /** - * A route name - * - * @var string - */ - public $name; - - /** - * A route host - * - * @var string|null - */ - public $host; + /** @var array */ + public array $namePrefixes = []; - /** - * A route path - * - * @var string - */ - public $path; + /** @var array */ + public array $pathPrefixes = []; - /** - * A route methods - * - * @var string[] - */ - public $methods; + /** @var non-empty-string|null */ + public ?string $pattern = null; - /** - * A route middlewares - * - * @var string[] - */ - public $middlewares; - - /** - * A route attributes - * - * @var array - */ - public $attributes; - - /** - * A route summary - * - * @var string - */ - public $summary; - - /** - * A route description - * - * @var string - */ - public $description; - - /** - * A route tags - * - * @var string[] - */ - public $tags; - - /** - * A route priority - * - * @var int - */ - public $priority; - - /** - * Constructor of the class - * - * @param string $name The route name - * @param string|null $host The route host - * @param string $path The route path - * @param string|null $method The route method - * @param string[] $methods The route methods - * @param string[] $middlewares The route middlewares - * @param array $attributes The route attributes - * @param string $summary The route summary - * @param string $description The route description - * @param string[] $tags The route tags - * @param int $priority The route priority (default 0) - */ public function __construct( - string $name, - ?string $host = null, - string $path = '/', - ?string $method = null, - array $methods = [], - array $middlewares = [], - array $attributes = [], - string $summary = '', - string $description = '', - array $tags = [], - int $priority = 0 + public string $name, + public string $path = '', + /** @var array */ + public array $patterns = [], + /** @var array */ + public array $methods = [], + /** @var array */ + public array $attributes = [], + /** @var array */ + public array $middlewares = [], + /** @var array */ + public array $consumes = [], + /** @var array */ + public array $produces = [], + /** @var array */ + public array $tags = [], + public string $summary = '', + public string $description = '', + public bool $isDeprecated = false, + public bool $isApiOperation = false, + /** @var array|object|null */ + public array|object|null $apiOperationFields = null, + public int $priority = 0, ) { - if (isset($method)) { - $methods[] = $method; - } - - // if no methods are specified, - // such a route is a GET route. - if (empty($methods)) { - $methods[] = 'GET'; - } - - $this->name = $name; - $this->host = $host; - $this->path = $path; - $this->methods = $methods; - $this->middlewares = $middlewares; - $this->attributes = $attributes; - $this->summary = $summary; - $this->description = $description; - $this->tags = $tags; - $this->priority = $priority; } } diff --git a/src/Annotation/SerializableResponse.php b/src/Annotation/SerializableResponse.php new file mode 100644 index 00000000..1ef67ef5 --- /dev/null +++ b/src/Annotation/SerializableResponse.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)] +abstract class SerializableResponse +{ + /** + * @return array + */ + abstract public function getMediaTypes(): array; +} diff --git a/src/Annotation/SlugPattern.php b/src/Annotation/SlugPattern.php new file mode 100644 index 00000000..2138a54a --- /dev/null +++ b/src/Annotation/SlugPattern.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\VariablePattern; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class SlugPattern extends Pattern +{ + public function __construct(string $variableName) + { + parent::__construct($variableName, VariablePattern::SLUG); + } +} diff --git a/src/Annotation/Summary.php b/src/Annotation/Summary.php new file mode 100644 index 00000000..9d0a3398 --- /dev/null +++ b/src/Annotation/Summary.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class Summary +{ + public function __construct( + public readonly string $value, + ) { + } +} diff --git a/src/Annotation/Tag.php b/src/Annotation/Tag.php new file mode 100644 index 00000000..55de6f94 --- /dev/null +++ b/src/Annotation/Tag.php @@ -0,0 +1,33 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class Tag +{ + /** + * @var array + */ + public readonly array $values; + + public function __construct(string ...$values) + { + $this->values = $values; + } +} diff --git a/src/Annotation/UintPattern.php b/src/Annotation/UintPattern.php new file mode 100644 index 00000000..c503aa48 --- /dev/null +++ b/src/Annotation/UintPattern.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\VariablePattern; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class UintPattern extends Pattern +{ + public function __construct(string $variableName) + { + parent::__construct($variableName, VariablePattern::UINT); + } +} diff --git a/src/Annotation/UuidPattern.php b/src/Annotation/UuidPattern.php new file mode 100644 index 00000000..bd7b486a --- /dev/null +++ b/src/Annotation/UuidPattern.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Annotation; + +use Attribute; +use Sunrise\Http\Router\Dictionary\VariablePattern; + +/** + * @since 3.0.0 + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class UuidPattern extends Pattern +{ + public function __construct(string $variableName) + { + parent::__construct($variableName, VariablePattern::UUID); + } +} diff --git a/src/ClassResolver.php b/src/ClassResolver.php new file mode 100644 index 00000000..3faf690a --- /dev/null +++ b/src/ClassResolver.php @@ -0,0 +1,85 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use ReflectionClass; +use ReflectionException; + +use function class_exists; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class ClassResolver implements ClassResolverInterface +{ + /** + * @var array + */ + private array $resolvedClasses = []; + + public function __construct( + private readonly ParameterResolverChainInterface $parameterResolverChain, + private readonly ?ContainerInterface $container, + ) { + } + + /** + * {@inheritDoc} + * + * @param class-string $className + * + * @return T + * + * @template T of object + * + * @throws ContainerExceptionInterface + * @throws InvalidArgumentException + * @throws ReflectionException + */ + public function resolveClass(string $className): object + { + if ($this->container?->has($className) === true) { + /** @var T */ + return $this->container->get($className); + } + + if (isset($this->resolvedClasses[$className])) { + /** @var T */ + return $this->resolvedClasses[$className]; + } + + if (!class_exists($className)) { + throw new InvalidArgumentException(sprintf('The class %s does not exist.', $className)); + } + + /** @var ReflectionClass $classReflection */ + $classReflection = new ReflectionClass($className); + + if (!$classReflection->isInstantiable()) { + throw new InvalidArgumentException(sprintf('The class %s is not instantiable.', $className)); + } + + $this->resolvedClasses[$className] = $classReflection->newInstance( + ...$this->parameterResolverChain->resolveParameters( + ...($classReflection->getConstructor()?->getParameters() ?? []) + ) + ); + + return $this->resolvedClasses[$className]; + } +} diff --git a/src/ClassResolverInterface.php b/src/ClassResolverInterface.php new file mode 100644 index 00000000..68a0ec5c --- /dev/null +++ b/src/ClassResolverInterface.php @@ -0,0 +1,35 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; + +/** + * @since 3.0.0 + */ +interface ClassResolverInterface +{ + /** + * Tries to resolve the given named class + * + * @param class-string $className + * + * @return T + * + * @template T of object + * + * @throws InvalidArgumentException + */ + public function resolveClass(string $className): object; +} diff --git a/src/Command/RouteListCommand.php b/src/Command/RouteListCommand.php deleted file mode 100644 index 15c7aff3..00000000 --- a/src/Command/RouteListCommand.php +++ /dev/null @@ -1,128 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Command; - -/** - * Import classes - */ -use RuntimeException; -use Sunrise\Http\Router\Router; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; - -/** - * Import functions - */ -use function join; -use function sprintf; -use function Sunrise\Http\Router\path_plain; - -/** - * This command will list all routes in your application - * - * If you cannot pass the router to the constructor - * or your architecture has problems with autowiring, - * then just inherit this class and override the getRouter method. - * - * @since 2.9.0 - */ -class RouteListCommand extends Command -{ - - /** - * {@inheritdoc} - */ - protected static $defaultName = 'router:route-list'; - - /** - * {@inheritdoc} - * - * @var string - */ - protected static $defaultDescription = 'Lists all routes in your application'; - - /** - * The router instance populated with routes - * - * @var Router|null - */ - private $router; - - /** - * Constructor of the class - * - * @param Router|null $router - */ - public function __construct(?Router $router = null) - { - parent::__construct(); - - $this->setName(static::$defaultName); - $this->setDescription(static::$defaultDescription); - - $this->router = $router; - } - - /** - * Gets the router instance populated with routes - * - * @return Router - * - * @throws RuntimeException - * If the command doesn't contain the router instance. - * - * @since 2.11.0 - */ - protected function getRouter() : Router - { - if (null === $this->router) { - throw new RuntimeException(sprintf( - 'The %2$s() method MUST return the %1$s class instance. ' . - 'Pass the %1$s class instance to the constructor, or override the %2$s() method.', - Router::class, - __METHOD__ - )); - } - - return $this->router; - } - - /** - * {@inheritdoc} - */ - final protected function execute(InputInterface $input, OutputInterface $output) : int - { - $table = new Table($output); - - $table->setHeaders([ - 'Name', - 'Host', - 'Path', - 'Verb', - ]); - - foreach ($this->getRouter()->getRoutes() as $route) { - $table->addRow([ - $route->getName(), - $route->getHost() ?? 'ANY', - path_plain($route->getPath()), - join(', ', $route->getMethods()), - ]); - } - - $table->render(); - - return 0; - } -} diff --git a/src/Command/RouterClearDescriptorsCacheCommand.php b/src/Command/RouterClearDescriptorsCacheCommand.php new file mode 100644 index 00000000..420ef01b --- /dev/null +++ b/src/Command/RouterClearDescriptorsCacheCommand.php @@ -0,0 +1,45 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Command; + +use Sunrise\Http\Router\Loader\DescriptorLoaderInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @since 3.0.0 + */ +#[AsCommand('router:clear-descriptors-cache', 'Clears the descriptors cache.')] +final class RouterClearDescriptorsCacheCommand extends Command +{ + public function __construct( + private readonly DescriptorLoaderInterface $descriptorLoader, + ) { + parent::__construct(); + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->descriptorLoader->clearCache(); + + $output->writeln('Done'); + + return self::SUCCESS; + } +} diff --git a/src/Command/RouterGenerateOpenApiDocumentCommand.php b/src/Command/RouterGenerateOpenApiDocumentCommand.php new file mode 100644 index 00000000..4da82793 --- /dev/null +++ b/src/Command/RouterGenerateOpenApiDocumentCommand.php @@ -0,0 +1,508 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Command; + +use ArrayAccess; +use BackedEnum; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionEnum; +use ReflectionException; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; +use ReflectionType; +use Sunrise\Http\Message\Response; +use Sunrise\Http\Router\Annotation\RequestBody; +use Sunrise\Http\Router\Annotation\RequestCookie; +use Sunrise\Http\Router\Annotation\RequestHeader; +use Sunrise\Http\Router\Annotation\ResponseStatus; +use Sunrise\Http\Router\Annotation\SerializableResponse; +use Sunrise\Http\Router\Helper\RouteBuilder; +use Sunrise\Http\Router\Helper\RouteParser; +use Sunrise\Http\Router\Helper\RouteSimplifier; +use Sunrise\Http\Router\RequestHandlerReflectorInterface; +use Sunrise\Http\Router\RouteInterface; +use Sunrise\Http\Router\RouterInterface; +use Sunrise\Hydrator\Annotation\Alias; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +use function array_merge; +use function class_exists; +use function compact; +use function date; +use function end; +use function in_array; +use function is_a; +use function is_subclass_of; +use function json_encode; +use function sprintf; +use function strtolower; + +use function strtr; +use const JSON_PRETTY_PRINT; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; +use const PHP_INT_SIZE; + +/** + * @since 3.0.0 + */ +#[AsCommand('router:generate-open-api-document', 'Generates OpenAPI Document.')] +final class RouterGenerateOpenApiDocumentCommand extends Command +{ + private const BUILT_IN_TYPE_BOOL = 'bool'; + private const BUILT_IN_TYPE_INT = 'int'; + private const BUILT_IN_TYPE_FLOAT = 'float'; + private const BUILT_IN_TYPE_STRING = 'string'; + private const BUILT_IN_TYPE_ARRAY = 'array'; + + private static array $schemas = []; + + public function __construct( + private readonly RouterInterface $router, + private readonly RequestHandlerReflectorInterface $requestHandlerReflector, + private readonly array $document, + private readonly string $dateTimeFormat, + ) { + parent::__construct(); + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $document = $this->document; + + foreach ($this->router->getRoutes() as $route) { + if (!$route->isApiOperation()) { + continue; + } + + $controller = $this->requestHandlerReflector->reflectRequestHandler($route->getRequestHandler()); + if (! $controller instanceof ReflectionMethod) { + continue; + } + + self::enrichDocumentWithPaths($route, $controller, $document); + } + + foreach (self::$schemas as $name => $schema) { + $document['components']['schemas'][$name] = $schema; + } + + /** @var string $json */ + $json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $output->writeln($json); + + return self::SUCCESS; + } + + private static function enrichDocumentWithPaths( + RouteInterface $route, + ReflectionMethod $controller, + array &$document, + ): void { + $operation = $route->getApiOperationFields(); + $operation['operationId'] = $route->getName(); + $operation['tags'] = $route->getTags(); + $operation['summary'] = $route->getSummary(); + $operation['description'] = $route->getDescription(); + $operation['deprecated'] = $route->isDeprecated(); + + self::enrichOperationWithPathParameters($route, $operation); + self::enrichOperationWithCookieAndHeaderParameters($controller, $operation); + self::enrichOperationWithRequestBody($route, $controller, $operation); + self::enrichOperationWithSerializableResponse($route, $controller, $operation); + + $routePath = RouteSimplifier::simplifyRoute($route->getPath()); + foreach ($route->getMethods() as $routeMethod) { + $routeMethod = strtolower($routeMethod); + $document['paths'][$routePath][$routeMethod] = $operation; + } + } + + private static function enrichOperationWithPathParameters(RouteInterface $route, array &$operation): void + { + $routeVars = RouteParser::parseRoute($route->getPath()); + $routePatterns = $route->getPatterns(); + $routeAttributes = $route->getAttributes(); + + foreach ($routeVars as $routeVar) { + $parameter = []; + $parameter['in'] = 'path'; + $parameter['name'] = $routeVar['name']; + $parameter['schema']['type'] = 'string'; + + $pattern = $routeVar['pattern'] ?? $routePatterns[$routeVar['name']] ?? null; + if ($pattern !== null) { + $parameter['schema']['pattern'] = '^' . $pattern . '$'; + } + + if (isset($routeAttributes[$routeVar['name']])) { + $default = RouteBuilder::stringifyValue($routeAttributes[$routeVar['name']]); + $parameter['schema']['default'] = $default; + } + + if (!isset($routeVar['optional_part'])) { + $parameter['required'] = true; + } + + $operation['parameters'][] = $parameter; + } + } + + private static function enrichOperationWithCookieAndHeaderParameters( + ReflectionMethod $endpoint, + array &$operation, + ): void { + foreach ($endpoint->getParameters() as $endpointParameter) { + /** @var list> $annotations */ + $annotations = array_merge( + $endpointParameter->getAttributes(RequestCookie::class), + $endpointParameter->getAttributes(RequestHeader::class), + ); + + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + + $operationParameter = []; + + $operationParameter['in'] = match ($annotation::class) { + RequestCookie::class => 'cookie', + RequestHeader::class => 'header', + }; + + $operationParameter['name'] = $annotation->name; + $operationParameter['schema'] = self::getStringSchema(); + + if (!$endpointParameter->isDefaultValueAvailable()) { + $operationParameter['required'] = true; + } + + $operation['parameters'][] = $operationParameter; + } + } + } + + private static function enrichOperationWithRequestBody( + RouteInterface $route, + ReflectionMethod $controller, + array &$operation, + ): void { + foreach ($controller->getParameters() as $parameter) { + if ($parameter->getAttributes(RequestBody::class) === []) { + continue; + } + + $schema = self::getTypeSchema($parameter->getType()); + + foreach ($route->getConsumedMediaTypes() as $mediaType) { + $operation['requestBody']['content'][$mediaType->getIdentifier()]['schema'] = $schema; + } + + return; + } + } + + private static function enrichOperationWithSerializableResponse( + RouteInterface $route, + ReflectionMethod $controller, + array &$operation, + ): void { + if ($controller->getAttributes(SerializableResponse::class, ReflectionAttribute::IS_INSTANCEOF) === []) { + return; + } + + $schema = self::getTypeSchema($controller->getReturnType()); + + $responseStatusCode = 200; + $responseStatusPhrase = 'Operation completed successfully.'; + + /** @var list> $annotations */ + $annotations = $controller->getAttributes(ResponseStatus::class); + if (isset($annotations[0])) { + $responseStatus = $annotations[0]->newInstance(); + $responseStatusCode = $responseStatus->code; + } + + foreach ($route->getProducedMediaTypes() as $mediaType) { + $operation['responses'][$responseStatusCode]['content'][$mediaType->getIdentifier()]['schema'] = $schema; + $operation['responses'][$responseStatusCode]['description'] = $responseStatusPhrase; + } + } + + /** + * @throws ReflectionException + */ + private static function getTypeSchema(?ReflectionType $type): array + { + if ($type === null) { + return []; + } + + $schema = []; + + $typeName = (string) $type; + + if ($typeName === self::BUILT_IN_TYPE_BOOL) { + $schema = self::getBoolSchema(); + } elseif ($typeName === self::BUILT_IN_TYPE_INT) { + $schema = self::getIntSchema(); + } elseif ($typeName === self::BUILT_IN_TYPE_FLOAT) { + $schema = self::getFloatSchema(); + } elseif ($typeName === self::BUILT_IN_TYPE_STRING) { + $schema = self::getStringSchema(); + } elseif ($typeName === self::BUILT_IN_TYPE_ARRAY) { + $schema = self::getArraySchema(); + } elseif (is_subclass_of($typeName, BackedEnum::class)) { + $schema = self::getEnumSchema($typeName, nullable: $type->allowsNull()); + } elseif (is_subclass_of($typeName, ArrayAccess::class)) { + $schema = self::getCollectionSchema($typeName); + } elseif (is_a($typeName, DateTimeImmutable::class, true)) { + $schema = self::getStringSchema(); + $schema['format'] = 'date-time'; + } elseif (class_exists($typeName)) { + $schema = self::getClassSchema($typeName); + } + + if ($type->allowsNull()) { + $schema['nullable'] = true; + } + + return $schema; + } + + private static function getBoolSchema(): array + { + return [ + 'type' => 'boolean', + ]; + } + + private static function getIntSchema(): array + { + return [ + 'type' => 'integer', + 'format' => PHP_INT_SIZE === 4 ? 'int32' : 'int64', + ]; + } + + private static function getFloatSchema(): array + { + return [ + 'type' => 'number', + 'format' => 'double', + ]; + } + + private static function getStringSchema(): array + { + return [ + 'type' => 'string', + ]; + } + + /** + * @return array{oneOf: array{0: array{type: 'array'}, 1: array{type: 'object'}}} + */ + private static function getArraySchema(): array + { + return [ + 'oneOf' => [ + [ + 'type' => 'array', + ], + [ + 'type' => 'object', + ], + ], + ]; + } + + /** + * @param class-string $enumName + * + * @throws ReflectionException + */ + private static function getEnumSchema(string $enumName, bool $nullable = false): array + { + $schemaName = strtr($enumName, '\\', '.'); + $schemaRef = self::getSchemaRef($schemaName); + + if (isset(self::$schemas[$schemaName])) { + return $schemaRef; + } + + $schema = match ((string) (new ReflectionEnum($enumName))->getBackingType()) { + self::BUILT_IN_TYPE_INT => self::getIntSchema(), + self::BUILT_IN_TYPE_STRING => self::getStringSchema(), + }; + + $schema['enum'] = []; + foreach ($enumName::cases() as $case) { + $schema['enum'][] = $case->value; + } + + // https://swagger.io/docs/specification/data-models/enums/#nullable + if ($nullable) { + $schema['enum'][] = null; + } + + self::$schemas[$schemaName] = $schema; + + return $schemaRef; + } + + /** + * @link https://github.com/sunrise-php/hydrator/blob/main/README.md#array + * + * @param class-string $className + * + * @throws ReflectionException + */ + private static function getCollectionSchema(string $className): array + { + $schemaName = strtr($className, '\\', '.'); + $schemaRef = self::getSchemaRef($schemaName); + + if (isset(self::$schemas[$schemaName])) { + return $schemaRef; + } + + $class = new ReflectionClass($className); + + $schema = self::getArraySchema(); + + $constructor = $class->getConstructor(); + if ($constructor === null) { + return $schema; + } + + $parameters = $constructor->getParameters(); + if ($parameters === []) { + return $schema; + } + + /** @var ReflectionParameter $parameter */ + $parameter = end($parameters); + if (!$parameter->isVariadic()) { + return $schema; + } + + $type = $parameter->getType(); + if ($type === null) { + return $schema; + } + + $elementSchema = self::getTypeSchema($type); + + $schema['oneOf'][0]['items'] = $elementSchema; + $schema['oneOf'][1]['additionalProperties'] = $elementSchema; + + self::$schemas[$schemaName] = $schema; + + return $schemaRef; + } + + /** + * @param class-string $className + * + * @throws ReflectionException + */ + private static function getClassSchema(string $className): array + { + $schemaName = strtr($className, '\\', '.'); + $schemaRef = self::getSchemaRef($schemaName); + + if (isset(self::$schemas[$schemaName])) { + return $schemaRef; + } + + $class = new ReflectionClass($className); + + $schema = [ + 'type' => 'object', + 'additionalProperties' => false, + ]; + + foreach ($class->getProperties() as $property) { + $propertyName = self::getPropertyName($property); + $propertySchema = self::getTypeSchema($property->getType()); + + $schema['properties'][$propertyName] = $propertySchema; + + if (!self::isOptionalProperty($property)) { + $schema['required'][] = $propertyName; + } + } + + self::$schemas[$schemaName] = $schema; + + return $schemaRef; + } + + /** + * @link https://github.com/sunrise-php/hydrator/blob/main/README.md#property-alias + */ + private static function getPropertyName(ReflectionProperty $property): string + { + /** @var list> $annotations */ + $annotations = $property->getAttributes(Alias::class); + if (isset($annotations[0])) { + $alias = $annotations[0]->newInstance(); + return $alias->value; + } + + return $property->getName(); + } + + /** + * @link https://github.com/sunrise-php/hydrator/blob/main/README.md#optional + */ + private static function isOptionalProperty(ReflectionProperty $property): bool + { + if ($property->hasDefaultValue()) { + return true; + } + + if (!$property->isPromoted()) { + return false; + } + + foreach ($property->getDeclaringClass()->getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->getName() === $property->getName()) { + return $parameter->isDefaultValueAvailable(); + } + } + + return false; // will never get here... + } + + private static function getSchemaRef(string $schemaName): array + { + return [ + '$ref' => '#/components/schemas/' . $schemaName, + ]; + } +} diff --git a/src/Command/RouterListRoutesCommand.php b/src/Command/RouterListRoutesCommand.php new file mode 100644 index 00000000..c3d1841c --- /dev/null +++ b/src/Command/RouterListRoutesCommand.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Command; + +use Sunrise\Http\Router\RouterInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +use function implode; + +/** + * @since 2.9.0 + */ +#[AsCommand('router:list-routes', 'Lists routes.')] +final class RouterListRoutesCommand extends Command +{ + public function __construct( + private readonly RouterInterface $router, + ) { + parent::__construct(); + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $table = new Table($output); + + $table->setHeaders([ + 'NAME', + 'PATH', + 'METHOD(S)', + ]); + + foreach ($this->router->getRoutes() as $route) { + $table->addRow([ + $route->getName(), + $route->getPath(), + $route->getMethods() === [] ? '*' : implode(', ', $route->getMethods()), + ]); + } + + $table->render(); + + return self::SUCCESS; + } +} diff --git a/src/Dictionary/ErrorMessage.php b/src/Dictionary/ErrorMessage.php new file mode 100644 index 00000000..d0409bdc --- /dev/null +++ b/src/Dictionary/ErrorMessage.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Dictionary; + +/** + * @since 3.0.0 + */ +final class ErrorMessage +{ + // phpcs:disable Generic.Files.LineLength.TooLong + public const MALFORMED_URI = 'The request URI is malformed and cannot be accepted by the server.'; + public const RESOURCE_NOT_FOUND = 'The requested resource was not found for this URI.'; + public const METHOD_NOT_ALLOWED = 'The requested method is not allowed for this resource; Check the Allow response header for allowed methods.'; + public const MISSING_CONTENT_TYPE = 'The request header Content-Type must be provided and cannot be empty; Check the Accept response header for supported media types.'; + public const UNSUPPORTED_MEDIA_TYPE = 'The media type {{ media_type }} is not supported; Check the Accept response header for supported media types.'; + public const INVALID_VARIABLE = 'The value of the variable {{{ variable_name }}} in the request URI {{ route_uri }} is invalid.'; + public const INVALID_QUERY = 'The request query parameters are invalid.'; + public const MISSING_HEADER = 'The request header {{ header_name }} must be provided.'; + public const INVALID_HEADER = 'The request header {{ header_name }} is invalid.'; + public const MISSING_COOKIE = 'The cookie {{ cookie_name }} is missing.'; + public const INVALID_COOKIE = 'The cookie {{ cookie_name }} is invalid.'; + public const INVALID_BODY = 'The request body is invalid.'; + public const EMPTY_JSON_PAYLOAD = 'The JSON payload cannot be empty.'; + public const INVALID_JSON_PAYLOAD = 'The JSON payload is invalid.'; + public const INVALID_JSON_PAYLOAD_FORMAT = 'The JSON payload must be in the format of an array or an object.'; + // phpcs:enable Generic.Files.LineLength.TooLong +} diff --git a/src/Dictionary/HeaderName.php b/src/Dictionary/HeaderName.php new file mode 100644 index 00000000..53563ac5 --- /dev/null +++ b/src/Dictionary/HeaderName.php @@ -0,0 +1,25 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Dictionary; + +/** + * @since 3.0.0 + */ +final class HeaderName +{ + public const ACCEPT = 'Accept'; + public const ACCEPT_LANGUAGE = 'Accept-Language'; + public const ALLOW = 'Allow'; + public const CONTENT_TYPE = 'Content-Type'; +} diff --git a/src/Dictionary/MediaType.php b/src/Dictionary/MediaType.php new file mode 100644 index 00000000..f719501c --- /dev/null +++ b/src/Dictionary/MediaType.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Dictionary; + +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; + +/** + * @since 3.0.0 + */ +enum MediaType: string implements MediaTypeInterface +{ + case JSON = 'application/json'; + case XML = 'application/xml'; + + public function getIdentifier(): string + { + return $this->value; + } +} diff --git a/src/Dictionary/PlaceholderCode.php b/src/Dictionary/PlaceholderCode.php new file mode 100644 index 00000000..f7076864 --- /dev/null +++ b/src/Dictionary/PlaceholderCode.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Dictionary; + +/** + * @since 3.0.0 + */ +final class PlaceholderCode +{ + public const COOKIE_NAME = '{{ cookie_name }}'; + public const HEADER_NAME = '{{ header_name }}'; + public const MEDIA_TYPE = '{{ media_type }}'; + public const REQUEST_METHOD = '{{ request_method }}'; + public const REQUEST_URI = '{{ request_uri }}'; + public const ROUTE_URI = '{{ route_uri }}'; + public const VARIABLE_NAME = '{{ variable_name }}'; +} diff --git a/src/Dictionary/VariablePattern.php b/src/Dictionary/VariablePattern.php new file mode 100644 index 00000000..c46a0b90 --- /dev/null +++ b/src/Dictionary/VariablePattern.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Dictionary; + +/** + * @since 3.0.0 + */ +final class VariablePattern +{ + public const SLUG = '[0-9A-Za-z]+(?:-[0-9A-Za-z]+)*'; + public const UINT = '[0-9]+'; + public const UUID = '[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}'; +} diff --git a/src/Entity/Locale/Locale.php b/src/Entity/Locale/Locale.php new file mode 100644 index 00000000..b23189a0 --- /dev/null +++ b/src/Entity/Locale/Locale.php @@ -0,0 +1,36 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\Locale; + +/** + * @since 3.0.0 + */ +final class Locale implements LocaleInterface +{ + public function __construct( + private readonly string $languageCode, + private readonly ?string $regionCode, + ) { + } + + public function getLanguageCode(): string + { + return $this->languageCode; + } + + public function getRegionCode(): ?string + { + return $this->regionCode; + } +} diff --git a/src/Entity/Locale/LocaleComparator.php b/src/Entity/Locale/LocaleComparator.php new file mode 100644 index 00000000..7a91595c --- /dev/null +++ b/src/Entity/Locale/LocaleComparator.php @@ -0,0 +1,53 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\Locale; + +/** + * @since 3.0.0 + */ +final class LocaleComparator +{ + /** + * @return int<-1, 1> + */ + public static function compare(LocaleInterface $a, LocaleInterface $b): int + { + $aLanguageCode = $a->getLanguageCode(); + if ($aLanguageCode === '*') { + return 0; + } + + $bLanguageCode = $b->getLanguageCode(); + if ($bLanguageCode === '*') { + return 0; + } + + $languagesCmp = $aLanguageCode <=> $bLanguageCode; + if ($languagesCmp !== 0) { + return $languagesCmp; + } + + $aRegionCode = $a->getRegionCode(); + if ($aRegionCode === null) { + return 0; + } + + $bRegionCode = $b->getRegionCode(); + if ($bRegionCode === null) { + return 0; + } + + return $aRegionCode <=> $bRegionCode; + } +} diff --git a/src/Entity/Locale/LocaleInterface.php b/src/Entity/Locale/LocaleInterface.php new file mode 100644 index 00000000..b01c8912 --- /dev/null +++ b/src/Entity/Locale/LocaleInterface.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\Locale; + +/** + * @since 3.0.0 + */ +interface LocaleInterface +{ + public function getLanguageCode(): string; + + public function getRegionCode(): ?string; +} diff --git a/src/Entity/MediaType/MediaType.php b/src/Entity/MediaType/MediaType.php new file mode 100644 index 00000000..a0020200 --- /dev/null +++ b/src/Entity/MediaType/MediaType.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\MediaType; + +/** + * @since 3.0.0 + */ +final class MediaType implements MediaTypeInterface +{ + public function __construct( + private readonly string $identifier, + ) { + } + + public function getIdentifier(): string + { + return $this->identifier; + } +} diff --git a/src/Entity/MediaType/MediaTypeComparator.php b/src/Entity/MediaType/MediaTypeComparator.php new file mode 100644 index 00000000..2bcd4bbf --- /dev/null +++ b/src/Entity/MediaType/MediaTypeComparator.php @@ -0,0 +1,42 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\MediaType; + +use function explode; +use function strtolower; + +/** + * @since 3.0.0 + */ +final class MediaTypeComparator +{ + /** + * @return int<-1, 1> + */ + public static function compare(MediaTypeInterface $a, MediaTypeInterface $b): int + { + $aId = strtolower($a->getIdentifier()); + $aParts = explode('/', $aId, 2); + $aParts[1] ??= ''; + + $bId = strtolower($b->getIdentifier()); + $bParts = explode('/', $bId, 2); + $bParts[1] ??= ''; + + $sameTypes = $aParts[0] === $bParts[0] || $aParts[0] === '*' || $bParts[0] === '*'; + $sameSubtypes = $aParts[1] === $bParts[1] || $aParts[1] === '*' || $bParts[1] === '*'; + + return $sameTypes && $sameSubtypes ? 0 : $aId <=> $bId; + } +} diff --git a/src/Entity/MediaType/MediaTypeInterface.php b/src/Entity/MediaType/MediaTypeInterface.php new file mode 100644 index 00000000..d2798ae1 --- /dev/null +++ b/src/Entity/MediaType/MediaTypeInterface.php @@ -0,0 +1,22 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\MediaType; + +/** + * @since 3.0.0 + */ +interface MediaTypeInterface +{ + public function getIdentifier(): string; +} diff --git a/src/Entity/MediaType/StringableMediaType.php b/src/Entity/MediaType/StringableMediaType.php new file mode 100644 index 00000000..54287888 --- /dev/null +++ b/src/Entity/MediaType/StringableMediaType.php @@ -0,0 +1,42 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Entity\MediaType; + +use Stringable; + +/** + * @since 3.0.0 + */ +final class StringableMediaType implements MediaTypeInterface, Stringable +{ + public function __construct( + private readonly MediaTypeInterface $mediaType, + ) { + } + + public static function create(MediaTypeInterface $mediaType): self + { + return new self($mediaType); + } + + public function getIdentifier(): string + { + return $this->mediaType->getIdentifier(); + } + + public function __toString(): string + { + return $this->getIdentifier(); + } +} diff --git a/src/Event/RouteEvent.php b/src/Event/RouteEvent.php deleted file mode 100644 index 08bda16c..00000000 --- a/src/Event/RouteEvent.php +++ /dev/null @@ -1,81 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Event; - -/** - * Import classes - */ -use Psr\Http\Message\ServerRequestInterface; -use Sunrise\Http\Router\RouteInterface; -use Symfony\Contracts\EventDispatcher\Event; - -/** - * RouteEvent - * - * @since 2.13.0 - */ -final class RouteEvent extends Event -{ - - /** - * @var string - */ - public const NAME = 'router.route'; - - /** - * @var RouteInterface - */ - private $route; - - /** - * @var ServerRequestInterface - */ - private $request; - - /** - * Constructor of the class - * - * @param RouteInterface $route - * @param ServerRequestInterface $request - */ - public function __construct(RouteInterface $route, ServerRequestInterface $request) - { - $this->route = $route; - $this->request = $request; - } - - /** - * @return RouteInterface - */ - public function getRoute() : RouteInterface - { - return $this->route; - } - - /** - * @return ServerRequestInterface - */ - public function getRequest() : ServerRequestInterface - { - return $this->request; - } - - /** - * @param ServerRequestInterface $request - * - * @return void - */ - public function setRequest(ServerRequestInterface $request) : void - { - $this->request = $request; - } -} diff --git a/src/Event/RoutePostRunEvent.php b/src/Event/RoutePostRunEvent.php new file mode 100644 index 00000000..42b3c3d9 --- /dev/null +++ b/src/Event/RoutePostRunEvent.php @@ -0,0 +1,31 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Event; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Sunrise\Http\Router\RouteInterface; + +/** + * @since 3.0.0 + */ +final class RoutePostRunEvent +{ + public function __construct( + public readonly RouteInterface $route, + public readonly ServerRequestInterface $request, + public ResponseInterface $response, + ) { + } +} diff --git a/src/Event/RoutePreRunEvent.php b/src/Event/RoutePreRunEvent.php new file mode 100644 index 00000000..97b80419 --- /dev/null +++ b/src/Event/RoutePreRunEvent.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Event; + +use Psr\Http\Message\ServerRequestInterface; +use Sunrise\Http\Router\RouteInterface; + +/** + * @since 3.0.0 + */ +final class RoutePreRunEvent +{ + public function __construct( + public readonly RouteInterface $route, + public ServerRequestInterface $request, + ) { + } +} diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php deleted file mode 100644 index 3cad99ee..00000000 --- a/src/Exception/BadRequestException.php +++ /dev/null @@ -1,43 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * BadRequestException - */ -class BadRequestException extends Exception -{ - - /** - * Gets errors - * - * @return mixed - * - * @since 2.9.0 - */ - public function getErrors() - { - return $this->fromContext('errors', []); - } - - /** - * Gets violations - * - * @return mixed - * - * @deprecated 2.9.0 Use the getErrors method. - */ - public function getViolations() - { - return $this->fromContext('violations', []); - } -} diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php deleted file mode 100644 index d773c958..00000000 --- a/src/Exception/Exception.php +++ /dev/null @@ -1,65 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * Import classes - */ -use RuntimeException; -use Throwable; - -/** - * Exception - */ -class Exception extends RuntimeException implements ExceptionInterface -{ - - /** - * Context of the exception - * - * @var array - */ - private $context = []; - - /** - * Constructor of the exception - * - * @param string $message - * @param array $context - * @param int $code - * @param Throwable|null $previous - * - * @link https://www.php.net/manual/en/exception.construct.php - */ - public function __construct(string $message = '', array $context = [], int $code = 0, ?Throwable $previous = null) - { - $this->context = $context; - - parent::__construct($message, $code, $previous); - } - - /** - * {@inheritdoc} - */ - final public function getContext() : array - { - return $this->context; - } - - /** - * {@inheritdoc} - */ - final public function fromContext($key, $default = null) - { - return $this->context[$key] ?? $default; - } -} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php deleted file mode 100644 index b0f0940c..00000000 --- a/src/Exception/ExceptionInterface.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * Import classes - */ -use Throwable; - -/** - * ExceptionInterface - */ -interface ExceptionInterface extends Throwable -{ - - /** - * Gets the exception context - * - * @return array - */ - public function getContext() : array; - - /** - * Gets a value from the exception context by the given key - * - * @param mixed $key - * @param mixed $default - * - * @return mixed - */ - public function fromContext($key, $default); -} diff --git a/src/Exception/HttpException.php b/src/Exception/HttpException.php new file mode 100644 index 00000000..527916e2 --- /dev/null +++ b/src/Exception/HttpException.php @@ -0,0 +1,116 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Exception; + +use Fig\Http\Message\StatusCodeInterface; +use RuntimeException; +use Stringable; +use Sunrise\Http\Router\Validation\ConstraintViolationInterface; +use Throwable; + +use function implode; +use function strtr; + +/** + * @since 3.0.0 + */ +class HttpException extends RuntimeException implements StatusCodeInterface +{ + /** + * The exception's non-interpolated message. + */ + private string $messageTemplate; + + /** + * @var array + */ + private array $messagePlaceholders = []; + + /** + * @var list + */ + private array $headerFields = []; + + /** + * @var list + */ + private array $constraintViolations = []; + + public function __construct(string $message, int $code, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->messageTemplate = $message; + } + + /** + * Returns the exception's non-interpolated message. + */ + final public function getMessageTemplate(): string + { + return $this->messageTemplate; + } + + /** + * @return array + */ + final public function getMessagePlaceholders(): array + { + return $this->messagePlaceholders; + } + + /** + * @return list + */ + final public function getHeaderFields(): array + { + return $this->headerFields; + } + + /** + * @return list + */ + final public function getConstraintViolations(): array + { + return $this->constraintViolations; + } + + final public function addMessagePlaceholder(string $placeholder, mixed $replacement): static + { + $this->message = strtr($this->message, [$placeholder => $replacement]); + + $this->messagePlaceholders[$placeholder] = $replacement; + + return $this; + } + + final public function addHeaderField(string $fieldName, string|Stringable ...$fieldValues): static + { + // https://datatracker.ietf.org/doc/html/rfc7230#section-7 + $fieldValue = implode(', ', $fieldValues); + + $this->headerFields[] = [$fieldName, $fieldValue]; + + return $this; + } + + final public function addConstraintViolation(ConstraintViolationInterface ...$constraintViolations): static + { + foreach ($constraintViolations as $constraintViolation) { + $this->constraintViolations[] = $constraintViolation; + } + + return $this; + } +} diff --git a/src/Exception/HttpExceptionFactory.php b/src/Exception/HttpExceptionFactory.php new file mode 100644 index 00000000..1af9a397 --- /dev/null +++ b/src/Exception/HttpExceptionFactory.php @@ -0,0 +1,228 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Exception; + +use Fig\Http\Message\StatusCodeInterface; +use Sunrise\Http\Router\Dictionary\ErrorMessage; +use Throwable; + +/** + * @since 3.0.0 + */ +final class HttpExceptionFactory +{ + public static function malformedUri( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::MALFORMED_URI, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function resourceNotFound( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::RESOURCE_NOT_FOUND, + $code ?? StatusCodeInterface::STATUS_NOT_FOUND, + $previous, + ); + } + + public static function methodNotAllowed( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::METHOD_NOT_ALLOWED, + $code ?? StatusCodeInterface::STATUS_METHOD_NOT_ALLOWED, + $previous, + ); + } + + public static function missingContentType( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::MISSING_CONTENT_TYPE, + $code ?? StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE, + $previous, + ); + } + + public static function unsupportedMediaType( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::UNSUPPORTED_MEDIA_TYPE, + $code ?? StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE, + $previous, + ); + } + + public static function invalidVariable( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_VARIABLE, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidQuery( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_QUERY, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function missingQueryParam( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::MISSING_QUERY_PARAM, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidQueryParam( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_QUERY_PARAM, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function missingHeader( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::MISSING_HEADER, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidHeader( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_HEADER, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function missingCookie( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::MISSING_COOKIE, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidCookie( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_COOKIE, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidBody( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_BODY, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function emptyJsonPayload( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::EMPTY_JSON_PAYLOAD, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidJsonPayload( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_JSON_PAYLOAD, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } + + public static function invalidJsonPayloadFormat( + ?string $message = null, + ?int $code = null, + ?Throwable $previous = null, + ): HttpException { + return new HttpException( + $message ?? ErrorMessage::INVALID_JSON_PAYLOAD_FORMAT, + $code ?? StatusCodeInterface::STATUS_BAD_REQUEST, + $previous, + ); + } +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php deleted file mode 100644 index 7319ec42..00000000 --- a/src/Exception/InvalidArgumentException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * InvalidArgumentException - * - * @since 2.9.0 - */ -class InvalidArgumentException extends Exception -{ -} diff --git a/src/Exception/InvalidAttributeValueException.php b/src/Exception/InvalidAttributeValueException.php deleted file mode 100644 index aa16e284..00000000 --- a/src/Exception/InvalidAttributeValueException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * InvalidAttributeValueException - */ -class InvalidAttributeValueException extends Exception -{ -} diff --git a/src/Exception/InvalidLoaderResourceException.php b/src/Exception/InvalidLoaderResourceException.php deleted file mode 100644 index 883a8112..00000000 --- a/src/Exception/InvalidLoaderResourceException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * InvalidLoaderResourceException - */ -class InvalidLoaderResourceException extends Exception -{ -} diff --git a/src/Exception/InvalidPathException.php b/src/Exception/InvalidPathException.php deleted file mode 100644 index ad788e8f..00000000 --- a/src/Exception/InvalidPathException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * InvalidPathException - */ -class InvalidPathException extends Exception -{ -} diff --git a/src/Exception/MethodNotAllowedException.php b/src/Exception/MethodNotAllowedException.php deleted file mode 100644 index 60101ad8..00000000 --- a/src/Exception/MethodNotAllowedException.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * Import functions - */ -use function implode; - -/** - * MethodNotAllowedException - */ -class MethodNotAllowedException extends Exception -{ - - /** - * Gets a method - * - * @return string - * - * @since 2.9.0 - */ - public function getMethod() : string - { - return $this->fromContext('method', ''); - } - - /** - * Gets allowed methods - * - * @return string[] - */ - public function getAllowedMethods() : array - { - return $this->fromContext('allowed', []); - } - - /** - * Gets joined allowed methods - * - * @return string - */ - public function getJoinedAllowedMethods() : string - { - return implode(',', $this->getAllowedMethods()); - } -} diff --git a/src/Exception/MissingAttributeValueException.php b/src/Exception/MissingAttributeValueException.php deleted file mode 100644 index 142d2d07..00000000 --- a/src/Exception/MissingAttributeValueException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * MissingAttributeValueException - */ -class MissingAttributeValueException extends Exception -{ -} diff --git a/src/Exception/PageNotFoundException.php b/src/Exception/PageNotFoundException.php deleted file mode 100644 index 1a26d248..00000000 --- a/src/Exception/PageNotFoundException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * PageNotFoundException - * - * @since 2.4.2 - */ -class PageNotFoundException extends RouteNotFoundException -{ -} diff --git a/src/Exception/RouteNotFoundException.php b/src/Exception/RouteNotFoundException.php deleted file mode 100644 index 8877a240..00000000 --- a/src/Exception/RouteNotFoundException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * RouteNotFoundException - */ -class RouteNotFoundException extends Exception -{ -} diff --git a/src/Exception/UndecodablePayloadException.php b/src/Exception/UndecodablePayloadException.php deleted file mode 100644 index 2a440230..00000000 --- a/src/Exception/UndecodablePayloadException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * UndecodablePayloadException - * - * @since 2.15.0 - */ -class UndecodablePayloadException extends BadRequestException -{ -} diff --git a/src/Exception/UnresolvableReferenceException.php b/src/Exception/UnresolvableReferenceException.php deleted file mode 100644 index a3b78143..00000000 --- a/src/Exception/UnresolvableReferenceException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * UnresolvableReferenceException - * - * @since 2.10.0 - */ -class UnresolvableReferenceException extends Exception -{ -} diff --git a/src/Exception/UnsupportedMediaTypeException.php b/src/Exception/UnsupportedMediaTypeException.php deleted file mode 100644 index 2f4c795b..00000000 --- a/src/Exception/UnsupportedMediaTypeException.php +++ /dev/null @@ -1,54 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Exception; - -/** - * Import functions - */ -use function implode; - -/** - * UnsupportedMediaTypeException - */ -class UnsupportedMediaTypeException extends Exception -{ - - /** - * Gets a type - * - * @return string - */ - public function getType() : string - { - return $this->fromContext('type', ''); - } - - /** - * Gets supported types - * - * @return string[] - */ - public function getSupportedTypes() : array - { - return $this->fromContext('supported', []); - } - - /** - * Gets joined supported types - * - * @return string - */ - public function getJoinedSupportedTypes() : string - { - return implode(',', $this->getSupportedTypes()); - } -} diff --git a/src/Helper/ClassFinder.php b/src/Helper/ClassFinder.php new file mode 100644 index 00000000..ddc1e7a8 --- /dev/null +++ b/src/Helper/ClassFinder.php @@ -0,0 +1,127 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use Generator; +use InvalidArgumentException; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use ReflectionClass; +use ReflectionException; +use RegexIterator; +use SplFileInfo; +use SplStack; + +use function get_declared_classes; +use function is_dir; +use function is_file; +use function realpath; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class ClassFinder +{ + /** + * @return Generator> + * + * @throws InvalidArgumentException + * @throws ReflectionException + */ + public static function getDirClasses(string $dirname): Generator + { + if (!is_dir($dirname)) { + throw new InvalidArgumentException(sprintf( + 'The directory "%s" does not exist.', + $dirname, + )); + } + + /** @var iterable $files */ + $files = new RegexIterator( + new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dirname) + ), + '/\.php$/', + ); + + /** @var array $filenames */ + $filenames = []; + foreach ($files as $file) { + $filename = $file->getRealPath(); + + (static function (string $filename): void { + /** @psalm-suppress UnresolvableInclude */ + require_once $filename; + })($filename); + + $filenames[$filename] = true; + } + + foreach (get_declared_classes() as $className) { + $classReflection = new ReflectionClass($className); + if (isset($filenames[$classReflection->getFileName()])) { + yield $className => $classReflection; + } + } + } + + /** + * @return Generator> + * + * @throws InvalidArgumentException + * @throws ReflectionException + */ + public static function getFileClasses(string $filename): Generator + { + if (!is_file($filename)) { + throw new InvalidArgumentException(sprintf( + 'The file "%s" does not exist.', + $filename, + )); + } + + /** @var string $filename */ + $filename = realpath($filename); + + (static function (string $filename): void { + /** @psalm-suppress UnresolvableInclude */ + require_once $filename; + })($filename); + + foreach (get_declared_classes() as $className) { + $classReflection = new ReflectionClass($className); + if ($classReflection->getFileName() === $filename) { + yield $className => $classReflection; + } + } + } + + /** + * @param ReflectionClass $class + * + * @return SplStack> + */ + public static function getParentClasses(ReflectionClass $class): SplStack + { + /** @var SplStack> $parents */ + $parents = new SplStack(); + while ($class = $class->getParentClass()) { + $parents->push($class); + } + + return $parents; + } +} diff --git a/src/Helper/HeaderParser.php b/src/Helper/HeaderParser.php new file mode 100644 index 00000000..9cae4654 --- /dev/null +++ b/src/Helper/HeaderParser.php @@ -0,0 +1,152 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use function trim; + +/** + * @since 3.0.0 + */ +final class HeaderParser +{ + private const IN_IDENTIFIER = 1; + private const IN_PARAMETER_NAME = 2; + private const IN_PARAMETER_VALUE = 4; + private const IN_QUOTED_STRING = 8; + private const IN_QUOTED_PAIR = 16; + + private const MAX_FIELD_VALUE_LENGTH = 512; + + /** + * @link https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 + */ + private const RFC7230_FIELD_VALUE_CHARSET = [ + "\x09" => 1, "\x20" => 1, "\x21" => 1, "\x22" => 1, "\x23" => 1, "\x24" => 1, "\x25" => 1, "\x26" => 1, + "\x27" => 1, "\x28" => 1, "\x29" => 1, "\x2a" => 1, "\x2b" => 1, "\x2c" => 1, "\x2d" => 1, "\x2e" => 1, + "\x2f" => 1, "\x30" => 1, "\x31" => 1, "\x32" => 1, "\x33" => 1, "\x34" => 1, "\x35" => 1, "\x36" => 1, + "\x37" => 1, "\x38" => 1, "\x39" => 1, "\x3a" => 1, "\x3b" => 1, "\x3c" => 1, "\x3d" => 1, "\x3e" => 1, + "\x3f" => 1, "\x40" => 1, "\x41" => 1, "\x42" => 1, "\x43" => 1, "\x44" => 1, "\x45" => 1, "\x46" => 1, + "\x47" => 1, "\x48" => 1, "\x49" => 1, "\x4a" => 1, "\x4b" => 1, "\x4c" => 1, "\x4d" => 1, "\x4e" => 1, + "\x4f" => 1, "\x50" => 1, "\x51" => 1, "\x52" => 1, "\x53" => 1, "\x54" => 1, "\x55" => 1, "\x56" => 1, + "\x57" => 1, "\x58" => 1, "\x59" => 1, "\x5a" => 1, "\x5b" => 1, "\x5c" => 1, "\x5d" => 1, "\x5e" => 1, + "\x5f" => 1, "\x60" => 1, "\x61" => 1, "\x62" => 1, "\x63" => 1, "\x64" => 1, "\x65" => 1, "\x66" => 1, + "\x67" => 1, "\x68" => 1, "\x69" => 1, "\x6a" => 1, "\x6b" => 1, "\x6c" => 1, "\x6d" => 1, "\x6e" => 1, + "\x6f" => 1, "\x70" => 1, "\x71" => 1, "\x72" => 1, "\x73" => 1, "\x74" => 1, "\x75" => 1, "\x76" => 1, + "\x77" => 1, "\x78" => 1, "\x79" => 1, "\x7a" => 1, "\x7b" => 1, "\x7c" => 1, "\x7d" => 1, "\x7e" => 1, + "\x80" => 1, "\x81" => 1, "\x82" => 1, "\x83" => 1, "\x84" => 1, "\x85" => 1, "\x86" => 1, "\x87" => 1, + "\x88" => 1, "\x89" => 1, "\x8a" => 1, "\x8b" => 1, "\x8c" => 1, "\x8d" => 1, "\x8e" => 1, "\x8f" => 1, + "\x90" => 1, "\x91" => 1, "\x92" => 1, "\x93" => 1, "\x94" => 1, "\x95" => 1, "\x96" => 1, "\x97" => 1, + "\x98" => 1, "\x99" => 1, "\x9a" => 1, "\x9b" => 1, "\x9c" => 1, "\x9d" => 1, "\x9e" => 1, "\x9f" => 1, + "\xa0" => 1, "\xa1" => 1, "\xa2" => 1, "\xa3" => 1, "\xa4" => 1, "\xa5" => 1, "\xa6" => 1, "\xa7" => 1, + "\xa8" => 1, "\xa9" => 1, "\xaa" => 1, "\xab" => 1, "\xac" => 1, "\xad" => 1, "\xae" => 1, "\xaf" => 1, + "\xb0" => 1, "\xb1" => 1, "\xb2" => 1, "\xb3" => 1, "\xb4" => 1, "\xb5" => 1, "\xb6" => 1, "\xb7" => 1, + "\xb8" => 1, "\xb9" => 1, "\xba" => 1, "\xbb" => 1, "\xbc" => 1, "\xbd" => 1, "\xbe" => 1, "\xbf" => 1, + "\xc0" => 1, "\xc1" => 1, "\xc2" => 1, "\xc3" => 1, "\xc4" => 1, "\xc5" => 1, "\xc6" => 1, "\xc7" => 1, + "\xc8" => 1, "\xc9" => 1, "\xca" => 1, "\xcb" => 1, "\xcc" => 1, "\xcd" => 1, "\xce" => 1, "\xcf" => 1, + "\xd0" => 1, "\xd1" => 1, "\xd2" => 1, "\xd3" => 1, "\xd4" => 1, "\xd5" => 1, "\xd6" => 1, "\xd7" => 1, + "\xd8" => 1, "\xd9" => 1, "\xda" => 1, "\xdb" => 1, "\xdc" => 1, "\xdd" => 1, "\xde" => 1, "\xdf" => 1, + "\xe0" => 1, "\xe1" => 1, "\xe2" => 1, "\xe3" => 1, "\xe4" => 1, "\xe5" => 1, "\xe6" => 1, "\xe7" => 1, + "\xe8" => 1, "\xe9" => 1, "\xea" => 1, "\xeb" => 1, "\xec" => 1, "\xed" => 1, "\xee" => 1, "\xef" => 1, + "\xf0" => 1, "\xf1" => 1, "\xf2" => 1, "\xf3" => 1, "\xf4" => 1, "\xf5" => 1, "\xf6" => 1, "\xf7" => 1, + "\xf8" => 1, "\xf9" => 1, "\xfa" => 1, "\xfb" => 1, "\xfc" => 1, "\xfd" => 1, "\xfe" => 1, "\xff" => 1, + ]; + + /** + * @return array, array{0: string, 1: array}> + */ + public static function parseHeader(string $header): array + { + if ($header === '') { + return []; + } + + $cursor = self::IN_IDENTIFIER; + $value = 0; + $param = -1; + + /** @var array, array{0?: string, 1?: array, array{0?: string, 1?: string}>}> $values */ + $values = []; + + for ($offset = 0; isset($header[$offset]) && $offset < self::MAX_FIELD_VALUE_LENGTH; $offset++) { + if (!isset(self::RFC7230_FIELD_VALUE_CHARSET[$header[$offset]])) { + continue; + } + + if ($header[$offset] === ',' && !($cursor & self::IN_QUOTED_STRING)) { + $cursor = self::IN_IDENTIFIER; + $value++; + $param = -1; + continue; + } + if ($header[$offset] === ';' && !($cursor & self::IN_QUOTED_STRING)) { + $cursor = self::IN_PARAMETER_NAME; + $param++; + continue; + } + if ($header[$offset] === '=' && ($cursor & self::IN_PARAMETER_NAME)) { + $cursor = self::IN_PARAMETER_VALUE; + continue; + } + if ($header[$offset] === '"' && ($cursor & self::IN_PARAMETER_VALUE) && !($cursor & self::IN_QUOTED_PAIR)) { + $cursor ^= self::IN_QUOTED_STRING; + continue; + } + if ($header[$offset] === '\\' && ($cursor & self::IN_QUOTED_STRING) && !($cursor & self::IN_QUOTED_PAIR)) { + $cursor |= self::IN_QUOTED_PAIR; + continue; + } + + if (($cursor & self::IN_IDENTIFIER)) { + $values[$value][0] ??= ''; + $values[$value][0] .= $header[$offset]; + continue; + } + if (($cursor & self::IN_PARAMETER_NAME)) { + $values[$value][1][$param][0] ??= ''; + $values[$value][1][$param][0] .= $header[$offset]; + continue; + } + if (($cursor & self::IN_PARAMETER_VALUE)) { + $values[$value][1][$param][1] ??= ''; + $values[$value][1][$param][1] .= $header[$offset]; + $cursor &= ~self::IN_QUOTED_PAIR; + continue; + } + } + + $result = []; + foreach ($values as $index => $value) { + unset($values[$index]); + + $value[0] = trim($value[0] ?? ''); + if ($value[0] === '') { + continue; + } + + $params = []; + foreach ($value[1] ?? [] as $param) { + $param[0] = trim($param[0] ?? ''); + if ($param[0] === '') { + continue; + } + + $params[$param[0]] = trim($param[1] ?? ''); + } + + $result[$index] = [$value[0], $params]; + } + + return $result; + } +} diff --git a/src/Helper/RouteBuilder.php b/src/Helper/RouteBuilder.php new file mode 100644 index 00000000..dd20ce50 --- /dev/null +++ b/src/Helper/RouteBuilder.php @@ -0,0 +1,107 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use BackedEnum; +use InvalidArgumentException; +use Stringable; + +use function get_debug_type; +use function is_int; +use function is_string; +use function sprintf; +use function str_replace; + +/** + * @since 3.0.0 + */ +final class RouteBuilder +{ + /** + * @param array $values Values for the route's variables. + * + * @throws InvalidArgumentException + */ + public static function buildRoute(string $route, array $values = []): string + { + $variables = RouteParser::parseRoute($route); + + $search = []; + $replace = []; + foreach ($variables as $variable) { + if (isset($values[$variable['name']])) { + try { + $value = self::stringifyValue($values[$variable['name']]); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException(sprintf( + 'The route %s could not be built due to an invalid value for the variable {%s}: %s.', + $route, + $variable['name'], + $e->getMessage(), + )); + } + + $search[] = $variable['statement']; + $replace[] = $value; + continue; + } + + if (isset($variable['optional_part'])) { + $search[] = $variable['optional_part']; + $replace[] = ''; + continue; + } + + throw new InvalidArgumentException(sprintf( + 'The route %s could not be built because the required value for the variable {%s} is missing.', + $route, + $variable['name'], + )); + } + + // will be replaced by an empty string: + // https://github.com/php/php-src/blob/a04577fb4ab5e1ebc7779608523b95ddf01e6c7f/ext/standard/string.c#L4406-L4408 + $search[] = '('; + $search[] = ')'; + + return str_replace($search, $replace, $route); + } + + /** + * Tries to cast the given value to the string type + * + * @throws InvalidArgumentException + */ + public static function stringifyValue(mixed $value): string + { + if (is_string($value)) { + return $value; + } + if (is_int($value)) { + return (string) $value; + } + if ($value instanceof BackedEnum) { + return (string) $value->value; + } + if ($value instanceof Stringable) { + return (string) $value; + } + + throw new InvalidArgumentException(sprintf( + 'The %s value could not be converted to a string; ' . + 'supported types are: string, integer, backed enum and stringable object.', + get_debug_type($value), + )); + } +} diff --git a/src/Helper/RouteCompiler.php b/src/Helper/RouteCompiler.php new file mode 100644 index 00000000..bb1fc184 --- /dev/null +++ b/src/Helper/RouteCompiler.php @@ -0,0 +1,61 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use InvalidArgumentException; + +use function addcslashes; +use function str_replace; + +/** + * @since 3.0.0 + */ +final class RouteCompiler +{ + /** + * @param array $patterns + * + * @return non-empty-string + * + * @throws InvalidArgumentException + */ + public static function compileRoute(string $route, array $patterns = []): string + { + $variables = RouteParser::parseRoute($route); + + $search = []; + $replace = []; + foreach ($variables as $variable) { + $search[] = $variable['statement']; + $replace[] = '{' . $variable['name'] . '}'; + } + + $route = str_replace($search, $replace, $route); + $route = addcslashes($route, '#$*+-.?[\]^|'); + $route = str_replace(['(', ')'], ['(?:', ')?'], $route); + + $search = []; + $replace = []; + foreach ($variables as $variable) { + $pattern = $patterns[$variable['name']] ?? $variable['pattern'] ?? '[^/]+'; + + $search[] = '{' . $variable['name'] . '}'; + $replace[] = '(?<' . $variable['name'] . '>' . $pattern . ')'; + } + + $route = str_replace($search, $replace, $route); + + return '#^' . $route . '$#uD'; + } +} diff --git a/src/Helper/RouteMatcher.php b/src/Helper/RouteMatcher.php new file mode 100644 index 00000000..81ee5f79 --- /dev/null +++ b/src/Helper/RouteMatcher.php @@ -0,0 +1,77 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use ErrorException; +use InvalidArgumentException; +use Throwable; +use UnexpectedValueException; + +use function is_int; +use function preg_last_error; +use function preg_last_error_msg; +use function preg_match; +use function sprintf; + +use const E_WARNING; +use const PREG_BAD_UTF8_ERROR; +use const PREG_UNMATCHED_AS_NULL; + +/** + * @since 3.0.0 + */ +final class RouteMatcher +{ + /** + * @param non-empty-string $pattern + * @param ?array $matches + * @param-out array $matches + * + * @throws InvalidArgumentException + * @throws UnexpectedValueException + */ + public static function matchRoute(string $route, string $pattern, string $subject, ?array &$matches = null): bool + { + try { + if (($result = @preg_match($pattern, $subject, $matches, PREG_UNMATCHED_AS_NULL)) === false) { + throw new ErrorException(preg_last_error_msg(), preg_last_error(), E_WARNING); + } + } catch (Throwable) { + if (preg_last_error() === PREG_BAD_UTF8_ERROR) { + throw new UnexpectedValueException(sprintf( + 'The route %s could not be matched due to an invalid subject: %s.', + $route, + preg_last_error_msg(), + )); + } + + throw new InvalidArgumentException(sprintf( + 'The route %s could not be matched due to: %s. ' . + 'This problem is most likely related to one of the route patterns.', + $route, + preg_last_error_msg(), + ), preg_last_error()); + } + + foreach ($matches as $key => $match) { + if (is_int($key) || $match === null) { + unset($matches[$key]); + } + } + + /** @var array $matches */ + + return $result === 1; + } +} diff --git a/src/Helper/RouteParser.php b/src/Helper/RouteParser.php new file mode 100644 index 00000000..d04fd740 --- /dev/null +++ b/src/Helper/RouteParser.php @@ -0,0 +1,309 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use InvalidArgumentException; + +use function sprintf; + +/** + * @since 3.0.0 + */ +final class RouteParser +{ + private const IN_VARIABLE = 1; + private const IN_VARIABLE_NAME = 2; + private const IN_VARIABLE_PATTERN = 8; + private const IN_OPTIONAL_PART = 16; + private const IN_OCCUPIED_PART = 32; + + /** + * @link https://www.pcre.org/original/doc/html/pcrepattern.html#SEC16 + */ + private const PCRE_SUBPATTERN_NAME_CHARSET = [ + "\x30" => 1, "\x31" => 1, "\x32" => 1, "\x33" => 1, "\x34" => 1, "\x35" => 1, "\x36" => 1, "\x37" => 1, + "\x38" => 1, "\x39" => 1, "\x41" => 1, "\x42" => 1, "\x43" => 1, "\x44" => 1, "\x45" => 1, "\x46" => 1, + "\x47" => 1, "\x48" => 1, "\x49" => 1, "\x4a" => 1, "\x4b" => 1, "\x4c" => 1, "\x4d" => 1, "\x4e" => 1, + "\x4f" => 1, "\x50" => 1, "\x51" => 1, "\x52" => 1, "\x53" => 1, "\x54" => 1, "\x55" => 1, "\x56" => 1, + "\x57" => 1, "\x58" => 1, "\x59" => 1, "\x5a" => 1, "\x5f" => 1, "\x61" => 1, "\x62" => 1, "\x63" => 1, + "\x64" => 1, "\x65" => 1, "\x66" => 1, "\x67" => 1, "\x68" => 1, "\x69" => 1, "\x6a" => 1, "\x6b" => 1, + "\x6c" => 1, "\x6d" => 1, "\x6e" => 1, "\x6f" => 1, "\x70" => 1, "\x71" => 1, "\x72" => 1, "\x73" => 1, + "\x74" => 1, "\x75" => 1, "\x76" => 1, "\x77" => 1, "\x78" => 1, "\x79" => 1, "\x7a" => 1, + ]; + + private const REGEX_DELIMITER = '#'; + + /** + * Parses the given route and returns its variables + * + * @return list + * + * @throws InvalidArgumentException + */ + public static function parseRoute(string $route): array + { + $cursor = 0; + $variable = -1; + + /** @var list $variables */ + $variables = []; + + /** @var array $names */ + $names = []; + + $left = $right = ''; + + for ($offset = 0; isset($route[$offset]); $offset++) { + if ($route[$offset] === '(' && !($cursor & self::IN_VARIABLE)) { + if (($cursor & self::IN_OPTIONAL_PART)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to open an optional part at position %d failed ' . + 'because nested optional parts are not supported.', + $route, + $offset, + )); + } + + $cursor |= self::IN_OPTIONAL_PART; + continue; + } + if ($route[$offset] === ')' && !($cursor & self::IN_VARIABLE)) { + if (!($cursor & self::IN_OPTIONAL_PART)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to close an optional part at position %d failed ' . + 'because an open optional part was not found.', + $route, + $offset, + )); + } + + if (($cursor & self::IN_OCCUPIED_PART)) { + $cursor &= ~self::IN_OCCUPIED_PART; + // phpcs:ignore Generic.Files.LineLength.TooLong + $variables[$variable]['optional_part'] = '(' . $left . ($variables[$variable]['statement'] ?? '') . $right . ')'; + } + + $cursor &= ~self::IN_OPTIONAL_PART; + $left = $right = ''; + continue; + } + + if ($route[$offset] === '{' && !($cursor & self::IN_VARIABLE_PATTERN)) { + if (($cursor & self::IN_VARIABLE)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to open a variable at position %d failed ' . + 'because nested variables are not supported.', + $route, + $offset, + )); + } + if (($cursor & self::IN_OCCUPIED_PART)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to open a variable at position %d failed ' . + 'because more than one variable inside an optional part is not supported.', + $route, + $offset, + )); + } + + if (($cursor & self::IN_OPTIONAL_PART)) { + $cursor |= self::IN_OCCUPIED_PART; + } + + $cursor |= self::IN_VARIABLE | self::IN_VARIABLE_NAME; + $variable++; + continue; + } + if ($route[$offset] === '}' && !($cursor & self::IN_VARIABLE_PATTERN)) { + if (!($cursor & self::IN_VARIABLE)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to close a variable at position %d failed ' . + 'because an open variable was not found.', + $route, + $offset, + )); + } + if (!isset($variables[$variable]['name'])) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to close a variable at position %d failed ' . + 'because its name is required for its declaration.', + $route, + $offset, + )); + } + if (isset($names[$variables[$variable]['name']])) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" at position %d could not be parsed ' . + 'because the variable name "%s" is already in use.', + $route, + $offset, + $variables[$variable]['name'], + )); + } + + $cursor &= ~(self::IN_VARIABLE | self::IN_VARIABLE_NAME); + $variables[$variable]['statement'] = '{' . ($variables[$variable]['statement'] ?? '') . '}'; + $names[$variables[$variable]['name']] = true; // @phpstan-ignore-line + continue; + } + + if (($cursor & self::IN_VARIABLE)) { + $variables[$variable]['statement'] ??= ''; + $variables[$variable]['statement'] .= $route[$offset]; + } + + if ($route[$offset] === '<' && ($cursor & self::IN_VARIABLE)) { + if (($cursor & self::IN_VARIABLE_PATTERN)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to open a variable pattern at position %d failed ' . + 'because nested patterns are not supported.', + $route, + $offset, + )); + } + if (!($cursor & self::IN_VARIABLE_NAME)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to open a variable pattern at position %d failed ' . + 'because the pattern must be preceded by the variable name.', + $route, + $offset, + )); + } + + $cursor = $cursor & ~self::IN_VARIABLE_NAME | self::IN_VARIABLE_PATTERN; + continue; + } + if ($route[$offset] === '>' && ($cursor & self::IN_VARIABLE)) { + if (!($cursor & self::IN_VARIABLE_PATTERN)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to close a variable pattern at position %d failed ' . + 'because an open pattern was not found.', + $route, + $offset, + )); + } + if (!isset($variables[$variable]['pattern'])) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to close a variable pattern at position %d failed ' . + 'because its content is required for its declaration.', + $route, + $offset, + )); + } + + $cursor &= ~self::IN_VARIABLE_PATTERN; + continue; + } + + // (left{var}right) + // ~^^^^~~~~~^^^^^~ + if (($cursor & self::IN_OPTIONAL_PART) && !($cursor & self::IN_VARIABLE)) { + if (!($cursor & self::IN_OCCUPIED_PART)) { + $left .= $route[$offset]; + } else { + $right .= $route[$offset]; + } + + continue; + } + + // https://www.pcre.org/original/doc/html/pcrepattern.html#SEC16 + if (($cursor & self::IN_VARIABLE_NAME)) { + if (!isset($variables[$variable]['name']) && $route[$offset] >= '0' && $route[$offset] <= '9') { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'An invalid character was found at position %d. ' . + 'Please note that variable names cannot start with digits.', + $route, + $offset, + )); + } + if (!isset(self::PCRE_SUBPATTERN_NAME_CHARSET[$route[$offset]])) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'An invalid character was found at position %d. ' . + 'Please note that variable names must consist only of digits, letters and underscores.', + $route, + $offset, + )); + } + if (isset($variables[$variable]['name'][31])) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'An extra character was found at position %d. ' . + 'Please note that variable names must not exceed 32 characters.', + $route, + $offset, + )); + } + + $variables[$variable]['name'] ??= ''; + $variables[$variable]['name'] .= $route[$offset]; + continue; + } + + if (($cursor & self::IN_VARIABLE_PATTERN)) { + if ($route[$offset] === self::REGEX_DELIMITER) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'An invalid character was found at position %d. ' . + 'Please note that variable patterns cannot contain the character "%s"; ' . + 'use an octal or hexadecimal sequence instead.', + $route, + $offset, + self::REGEX_DELIMITER, + )); + } + + $variables[$variable]['pattern'] ??= ''; + $variables[$variable]['pattern'] .= $route[$offset]; + continue; + } + + // {var<\w+>xxx} + // ~~~~~~~~~^^^~ + if (($cursor & self::IN_VARIABLE)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'An unexpected character was found at position %d; ' . + 'a variable at this position must be closed.', + $route, + $offset, + )); + } + } + + if (($cursor & self::IN_VARIABLE) || ($cursor & self::IN_OPTIONAL_PART)) { + throw new InvalidArgumentException(sprintf( + 'The route "%s" could not be parsed due to a syntax error. ' . + 'The attempt to parse the route failed ' . + 'because it contains an unclosed variable or optional part.', + $route, + )); + } + + /** @var list */ + return $variables; + } +} diff --git a/src/Helper/RouteSimplifier.php b/src/Helper/RouteSimplifier.php new file mode 100644 index 00000000..52a003f8 --- /dev/null +++ b/src/Helper/RouteSimplifier.php @@ -0,0 +1,46 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Helper; + +use InvalidArgumentException; + +use function str_replace; + +/** + * @since 3.0.0 + */ +final class RouteSimplifier +{ + /** + * @throws InvalidArgumentException + */ + public static function simplifyRoute(string $route): string + { + $variables = RouteParser::parseRoute($route); + + $search = []; + $replace = []; + foreach ($variables as $variable) { + $search[] = $variable['statement']; + $replace[] = '{' . $variable['name'] . '}'; + } + + // will be replaced by an empty string: + // https://github.com/php/php-src/blob/a04577fb4ab5e1ebc7779608523b95ddf01e6c7f/ext/standard/string.c#L4406-L4408 + $search[] = '('; + $search[] = ')'; + + return str_replace($search, $replace, $route); + } +} diff --git a/src/Loader/AnnotationDirectoryLoader.php b/src/Loader/AnnotationDirectoryLoader.php deleted file mode 100644 index 06985a40..00000000 --- a/src/Loader/AnnotationDirectoryLoader.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Loader; - -/** - * AnnotationDirectoryLoader - * - * @deprecated 2.6.0 Use the DescriptorLoader class. - */ -class AnnotationDirectoryLoader extends DescriptorLoader -{ -} diff --git a/src/Loader/ConfigLoader.php b/src/Loader/ConfigLoader.php deleted file mode 100644 index f57dfb1a..00000000 --- a/src/Loader/ConfigLoader.php +++ /dev/null @@ -1,161 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Loader; - -/** - * Import classes - */ -use Psr\Container\ContainerInterface; -use Sunrise\Http\Router\Exception\InvalidLoaderResourceException; -use Sunrise\Http\Router\ReferenceResolver; -use Sunrise\Http\Router\ReferenceResolverInterface; -use Sunrise\Http\Router\RouteCollectionFactory; -use Sunrise\Http\Router\RouteCollectionFactoryInterface; -use Sunrise\Http\Router\RouteCollectionInterface; -use Sunrise\Http\Router\RouteCollector; -use Sunrise\Http\Router\RouteFactory; -use Sunrise\Http\Router\RouteFactoryInterface; - -/** - * Import functions - */ -use function glob; -use function is_dir; -use function is_file; -use function sprintf; - -/** - * ConfigLoader - */ -class ConfigLoader implements LoaderInterface -{ - - /** - * @var string[] - */ - private $resources = []; - - /** - * @var RouteCollectionFactoryInterface - */ - private $collectionFactory; - - /** - * @var RouteFactoryInterface - */ - private $routeFactory; - - /** - * @var ReferenceResolverInterface - */ - private $referenceResolver; - - /** - * Constructor of the class - * - * @param RouteCollectionFactoryInterface|null $collectionFactory - * @param RouteFactoryInterface|null $routeFactory - * @param ReferenceResolverInterface|null $referenceResolver - */ - public function __construct( - ?RouteCollectionFactoryInterface $collectionFactory = null, - ?RouteFactoryInterface $routeFactory = null, - ?ReferenceResolverInterface $referenceResolver = null - ) { - $this->collectionFactory = $collectionFactory ?? new RouteCollectionFactory(); - $this->routeFactory = $routeFactory ?? new RouteFactory(); - $this->referenceResolver = $referenceResolver ?? new ReferenceResolver(); - } - - /** - * Gets the loader container - * - * @return ContainerInterface|null - * - * @since 2.9.0 - */ - public function getContainer() : ?ContainerInterface - { - return $this->referenceResolver->getContainer(); - } - - /** - * Sets the given container to the loader - * - * @param ContainerInterface|null $container - * - * @return void - * - * @since 2.9.0 - */ - public function setContainer(?ContainerInterface $container) : void - { - $this->referenceResolver->setContainer($container); - } - - /** - * {@inheritdoc} - */ - public function attach($resource) : void - { - if (is_dir($resource)) { - $fileNames = glob($resource . '/*.php'); - foreach ($fileNames as $fileName) { - $this->resources[] = $fileName; - } - - return; - } - - if (!is_file($resource)) { - throw new InvalidLoaderResourceException(sprintf( - 'The resource "%s" is not found.', - $resource - )); - } - - $this->resources[] = $resource; - } - - /** - * {@inheritdoc} - */ - public function attachArray(array $resources) : void - { - foreach ($resources as $resource) { - $this->attach($resource); - } - } - - /** - * {@inheritdoc} - */ - public function load() : RouteCollectionInterface - { - $collector = new RouteCollector( - $this->collectionFactory, - $this->routeFactory, - $this->referenceResolver - ); - - foreach ($this->resources as $resource) { - (function () use ($resource) { - /** - * @psalm-suppress UnresolvableInclude - */ - require $resource; - })->call($collector); - } - - return $collector->getCollection(); - } -} diff --git a/src/Loader/DescriptorDirectoryLoader.php b/src/Loader/DescriptorDirectoryLoader.php deleted file mode 100644 index 93614634..00000000 --- a/src/Loader/DescriptorDirectoryLoader.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router\Loader; - -/** - * DescriptorDirectoryLoader - * - * @deprecated 2.10.0 Use the DescriptorLoader class. - */ -class DescriptorDirectoryLoader extends DescriptorLoader -{ -} diff --git a/src/Loader/DescriptorLoader.php b/src/Loader/DescriptorLoader.php index 12ef0caa..ea5be9b9 100644 --- a/src/Loader/DescriptorLoader.php +++ b/src/Loader/DescriptorLoader.php @@ -1,463 +1,378 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Loader; -/** - * Import classes - */ -use Doctrine\Common\Annotations\SimpleAnnotationReader; -use Psr\Container\ContainerInterface; -use Psr\Http\Server\MiddlewareInterface; +use Generator; +use InvalidArgumentException; use Psr\Http\Server\RequestHandlerInterface; +use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; -use Sunrise\Http\Router\Annotation\Host; -use Sunrise\Http\Router\Annotation\Middleware; -use Sunrise\Http\Router\Annotation\Postfix; -use Sunrise\Http\Router\Annotation\Prefix; -use Sunrise\Http\Router\Annotation\Route; -use Sunrise\Http\Router\Exception\InvalidLoaderResourceException; -use Sunrise\Http\Router\Exception\UnresolvableReferenceException; -use Sunrise\Http\Router\ReferenceResolver; -use Sunrise\Http\Router\ReferenceResolverInterface; -use Sunrise\Http\Router\RouteCollectionFactory; -use Sunrise\Http\Router\RouteCollectionFactoryInterface; -use Sunrise\Http\Router\RouteCollectionInterface; -use Sunrise\Http\Router\RouteFactory; -use Sunrise\Http\Router\RouteFactoryInterface; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; +use ReflectionAttribute; use ReflectionClass; +use ReflectionException; use ReflectionMethod; -use Reflector; - -/** - * Import functions - */ -use function array_diff; +use Sunrise\Http\Router\Annotation\Consumes; +use Sunrise\Http\Router\Annotation\DefaultAttribute; +use Sunrise\Http\Router\Annotation\Deprecated; +use Sunrise\Http\Router\Annotation\Description; +use Sunrise\Http\Router\Annotation\Method; +use Sunrise\Http\Router\Annotation\Middleware; +use Sunrise\Http\Router\Annotation\NamePrefix; +use Sunrise\Http\Router\Annotation\Path; +use Sunrise\Http\Router\Annotation\PathPostfix; +use Sunrise\Http\Router\Annotation\PathPrefix; +use Sunrise\Http\Router\Annotation\Pattern; +use Sunrise\Http\Router\Annotation\Priority; +use Sunrise\Http\Router\Annotation\Produces; +use Sunrise\Http\Router\Annotation\Route as Descriptor; +use Sunrise\Http\Router\Annotation\SerializableResponse; +use Sunrise\Http\Router\Annotation\Summary; +use Sunrise\Http\Router\Annotation\Tag; +use Sunrise\Http\Router\Helper\ClassFinder; +use Sunrise\Http\Router\Helper\RouteCompiler; +use Sunrise\Http\Router\Route; + +use function array_map; use function class_exists; -use function get_declared_classes; -use function hash; +use function implode; use function is_dir; +use function is_file; use function sprintf; +use function strtoupper; use function usort; /** - * Import constants - */ -use const PHP_MAJOR_VERSION; - -/** - * DescriptorLoader + * @since 2.10.0 */ -class DescriptorLoader implements LoaderInterface +final class DescriptorLoader implements DescriptorLoaderInterface { - - /** - * @var class-string[] - */ - private $resources = []; - /** - * @var RouteCollectionFactoryInterface + * @since 3.0.0 */ - private $collectionFactory; + public const DESCRIPTORS_CACHE_KEY = 'sunrise_http_router_descriptors'; - /** - * @var RouteFactoryInterface - */ - private $routeFactory; - - /** - * @var ReferenceResolverInterface - */ - private $referenceResolver; - - /** - * @var SimpleAnnotationReader|null - */ - private $annotationReader = null; - - /** - * @var CacheInterface|null - */ - private $cache = null; - - /** - * @var string|null - */ - private $cacheKey = null; - - /** - * Constructor of the class - * - * @param RouteCollectionFactoryInterface|null $collectionFactory - * @param RouteFactoryInterface|null $routeFactory - * @param ReferenceResolverInterface|null $referenceResolver - */ public function __construct( - ?RouteCollectionFactoryInterface $collectionFactory = null, - ?RouteFactoryInterface $routeFactory = null, - ?ReferenceResolverInterface $referenceResolver = null + /** @var array */ + private readonly array $resources, + private readonly ?CacheInterface $cache = null, ) { - $this->collectionFactory = $collectionFactory ?? new RouteCollectionFactory(); - $this->routeFactory = $routeFactory ?? new RouteFactory(); - $this->referenceResolver = $referenceResolver ?? new ReferenceResolver(); - - // the "doctrine/annotations" package must be installed manually - if (class_exists(SimpleAnnotationReader::class)) { - $this->annotationReader = /** @scrutinizer ignore-deprecated */ new SimpleAnnotationReader(); - $this->annotationReader->addNamespace('Sunrise\Http\Router\Annotation'); - } } /** - * Gets the loader container + * @inheritDoc * - * @return ContainerInterface|null + * @throws CacheException + * @throws InvalidArgumentException + * @throws ReflectionException */ - public function getContainer() : ?ContainerInterface + public function load(): Generator { - return $this->referenceResolver->getContainer(); - } - - /** - * Gets the loader cache - * - * @return CacheInterface|null - */ - public function getCache() : ?CacheInterface - { - return $this->cache; - } - - /** - * Gets the loader cache key - * - * @return string|null - * - * @since 2.10.0 - */ - public function getCacheKey() : ?string - { - return $this->cacheKey; - } - - /** - * Sets the given container to the loader - * - * @param ContainerInterface|null $container - * - * @return void - */ - public function setContainer(?ContainerInterface $container) : void - { - $this->referenceResolver->setContainer($container); + foreach ($this->getDescriptors() as $descriptor) { + yield new Route( + name: $descriptor->name, + path: $descriptor->path, + requestHandler: $descriptor->holder, + patterns: $descriptor->patterns, + methods: $descriptor->methods, + attributes: $descriptor->attributes, + middlewares: $descriptor->middlewares, + consumes: $descriptor->consumes, + produces: $descriptor->produces, + tags: $descriptor->tags, + summary: $descriptor->summary, + description: $descriptor->description, + isDeprecated: $descriptor->isDeprecated, + isApiOperation: $descriptor->isApiOperation, + apiOperationFields: $descriptor->apiOperationFields, + pattern: $descriptor->pattern, + ); + } } /** - * Sets the given cache to the loader - * - * @param CacheInterface|null $cache - * - * @return void + * @throws CacheException */ - public function setCache(?CacheInterface $cache) : void + public function clearCache(): void { - $this->cache = $cache; + $this->cache?->delete(self::DESCRIPTORS_CACHE_KEY); } /** - * Sets the given cache key to the loader - * - * @param string|null $cacheKey + * @return list * - * @return void - * - * @since 2.10.0 + * @throws CacheException + * @throws InvalidArgumentException + * @throws ReflectionException */ - public function setCacheKey(?string $cacheKey) : void + private function getDescriptors(): array { - $this->cacheKey = $cacheKey; - } + /** @var list|null $descriptors */ + $descriptors = $this->cache?->get(self::DESCRIPTORS_CACHE_KEY); + if ($descriptors !== null) { + return $descriptors; + } - /** - * {@inheritdoc} - */ - public function attach($resource) : void - { - if (is_dir($resource)) { - $classNames = $this->scandir($resource); - foreach ($classNames as $className) { - $this->resources[] = $className; + $descriptors = []; + foreach ($this->resources as $resource) { + foreach (self::getResourceDescriptors($resource) as $descriptor) { + $descriptors[] = $descriptor; } - - return; } - if (!class_exists($resource)) { - throw new InvalidLoaderResourceException(sprintf( - 'The resource "%s" is not found.', - $resource - )); - } + usort($descriptors, static fn(Descriptor $a, Descriptor $b): int => $b->priority <=> $a->priority); - $this->resources[] = $resource; - } + $this->cache?->set(self::DESCRIPTORS_CACHE_KEY, $descriptors); - /** - * {@inheritdoc} - */ - public function attachArray(array $resources) : void - { - foreach ($resources as $resource) { - $this->attach($resource); - } + return $descriptors; } /** - * {@inheritdoc} + * @return Generator * - * @throws UnresolvableReferenceException - * If one of the found middlewares cannot be resolved. + * @throws InvalidArgumentException + * @throws ReflectionException */ - public function load() : RouteCollectionInterface + private static function getResourceDescriptors(string $resource): Generator { - $descriptors = $this->getCachedDescriptors(); - - $routes = []; - foreach ($descriptors as $descriptor) { - $middlewares = []; - foreach ($descriptor->middlewares as $className) { - $middlewares[] = $this->referenceResolver->toMiddleware($className); + if (is_dir($resource)) { + foreach (ClassFinder::getDirClasses($resource) as $class) { + yield from self::getClassDescriptors($class); } - $routes[] = $this->routeFactory->createRoute( - $descriptor->name, - $descriptor->path, - $descriptor->methods, - $this->referenceResolver->toRequestHandler($descriptor->holder), - $middlewares, - $descriptor->attributes - ) - ->setHost($descriptor->host) - ->setSummary($descriptor->summary) - ->setDescription($descriptor->description) - ->setTags(...$descriptor->tags); - } - - return $this->collectionFactory->createCollection(...$routes); - } - - /** - * Gets descriptors from the cache if they are stored in it, - * otherwise collects them from the loader resources, - * and then tries to cache them - * - * @return Route[] - */ - private function getCachedDescriptors() : array - { - $key = $this->cacheKey ?? hash('md5', 'router:descriptors'); - - if ($this->cache && $this->cache->has($key)) { - return $this->cache->get($key); + return; } - $result = $this->collectDescriptors(); + if (is_file($resource)) { + foreach (ClassFinder::getFileClasses($resource) as $class) { + yield from self::getClassDescriptors($class); + } - if ($this->cache) { - $this->cache->set($key, $result); + return; } - return $result; - } + if (class_exists($resource)) { + yield from self::getClassDescriptors(new ReflectionClass($resource)); - /** - * Collects descriptors from the loader resources - * - * @return Route[] - */ - private function collectDescriptors() : array - { - $result = []; - foreach ($this->resources as $resource) { - $class = new ReflectionClass($resource); - $descriptors = $this->getClassDescriptors($class); - foreach ($descriptors as $descriptor) { - $result[] = $descriptor; - } + return; } - usort($result, function (Route $a, Route $b) : int { - return $b->priority <=> $a->priority; - }); - - return $result; + throw new InvalidArgumentException(sprintf( + 'The loader %s only accepts directory, file or class names; ' . + 'however, the resource %s is not one of them.', + self::class, + $resource, + )); } /** - * Gets descriptors from the given class + * @param ReflectionClass $class * - * @param ReflectionClass $class + * @return Generator * - * @return Route[] + * @throws InvalidArgumentException */ - private function getClassDescriptors(ReflectionClass $class) : array + private static function getClassDescriptors(ReflectionClass $class): Generator { - if ($class->isAbstract()) { - return []; + if (!$class->isInstantiable()) { + return; } - $result = []; - if ($class->isSubclassOf(RequestHandlerInterface::class)) { - $annotations = $this->getAnnotations($class, Route::class); + /** @var list> $annotations */ + $annotations = $class->getAttributes(Descriptor::class, ReflectionAttribute::IS_INSTANCEOF); if (isset($annotations[0])) { - $descriptor = $annotations[0]; - $this->supplementDescriptor($descriptor, $class); + $descriptor = $annotations[0]->newInstance(); $descriptor->holder = $class->getName(); - $result[] = $descriptor; + self::enrichDescriptorFromParentClasses($descriptor, $class); + self::enrichDescriptorFromClassOrMethod($descriptor, $class); + self::completeDescriptor($descriptor); + yield $descriptor; } } foreach ($class->getMethods() as $method) { - // ignore non-available methods... - if ($method->isStatic() || - $method->isPrivate() || - $method->isProtected()) { + if (!$method->isPublic() || $method->isStatic()) { continue; } - $annotations = $this->getAnnotations($method, Route::class); + /** @var list> $annotations */ + $annotations = $method->getAttributes(Descriptor::class, ReflectionAttribute::IS_INSTANCEOF); if (isset($annotations[0])) { - $descriptor = $annotations[0]; - $this->supplementDescriptor($descriptor, $class); - $this->supplementDescriptor($descriptor, $method); + $descriptor = $annotations[0]->newInstance(); $descriptor->holder = [$class->getName(), $method->getName()]; - $result[] = $descriptor; + self::enrichDescriptorFromParentClasses($descriptor, $class); + self::enrichDescriptorFromClassOrMethod($descriptor, $class); + self::enrichDescriptorFromClassOrMethod($descriptor, $method); + self::completeDescriptor($descriptor); + yield $descriptor; } } + } - return $result; + /** + * @param ReflectionClass $class + */ + private static function enrichDescriptorFromParentClasses(Descriptor $descriptor, ReflectionClass $class): void + { + foreach (ClassFinder::getParentClasses($class) as $parent) { + self::enrichDescriptorFromClassOrMethod($descriptor, $parent); + } } /** - * Supplements the given descriptor from the given class or method with data such as: - * host, path prefix, path postfix and middlewares - * - * ```php - * #[Prefix('/api/v1')] - * class SomeController { - * - * #[Route('foo', path: '/foo')] - * public function foo() { - * // will be available at: /api/v1/foo - * } - * - * #[Route('bar', path: '/bar')] - * public function bar() { - * // will be available at: /api/v1/bar - * } - * } - * ``` + * @param ReflectionClass|ReflectionMethod $classOrMethod * - * @param Route $descriptor - * @param ReflectionClass|ReflectionMethod $reflector - * - * @return void + * @throws InvalidArgumentException */ - private function supplementDescriptor(Route $descriptor, Reflector $reflector) : void - { - $annotations = $this->getAnnotations($reflector, Host::class); + private static function enrichDescriptorFromClassOrMethod( + Descriptor $descriptor, + ReflectionClass|ReflectionMethod $classOrMethod, + ): void { + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(NamePrefix::class); + if (isset($annotations[0])) { + $annotation = $annotations[0]->newInstance(); + $descriptor->namePrefixes[] = $annotation->value; + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Path::class); if (isset($annotations[0])) { - $descriptor->host = $annotations[0]->value; + $annotation = $annotations[0]->newInstance(); + $descriptor->path = $annotation->value; } - $annotations = $this->getAnnotations($reflector, Prefix::class); + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(PathPrefix::class); if (isset($annotations[0])) { - $descriptor->path = $annotations[0]->value . $descriptor->path; + $annotation = $annotations[0]->newInstance(); + $descriptor->pathPrefixes[] = $annotation->value; } - $annotations = $this->getAnnotations($reflector, Postfix::class); + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(PathPostfix::class); if (isset($annotations[0])) { - $descriptor->path = $descriptor->path . $annotations[0]->value; + $annotation = $annotations[0]->newInstance(); + $descriptor->path .= $annotation->value; } - $annotations = $this->getAnnotations($reflector, Middleware::class); + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Pattern::class, ReflectionAttribute::IS_INSTANCEOF); foreach ($annotations as $annotation) { - $descriptor->middlewares[] = $annotation->value; + $annotation = $annotation->newInstance(); + $descriptor->patterns[$annotation->variableName] = $annotation->value; } - } - /** - * Gets annotations from the given class or method - * - * @param ReflectionClass|ReflectionMethod $reflector - * @param class-string $annotationName - * - * @return T[] - * - * @template T - */ - private function getAnnotations(Reflector $reflector, string $annotationName) : array - { - $result = []; + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Method::class, ReflectionAttribute::IS_INSTANCEOF); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + foreach ($annotation->values as $value) { + $descriptor->methods[] = $value; + } + } - if (8 === PHP_MAJOR_VERSION) { - $attributes = $reflector->getAttributes($annotationName); - foreach ($attributes as $attribute) { - $result[] = $attribute->newInstance(); + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(DefaultAttribute::class); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + $descriptor->attributes[$annotation->name] = $annotation->value; + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Middleware::class); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + foreach ($annotation->values as $value) { + $descriptor->middlewares[] = $value; } } - if (empty($result) and isset($this->annotationReader)) { - $annotations = ($reflector instanceof ReflectionClass) ? - $this->annotationReader->getClassAnnotations($reflector) : - $this->annotationReader->getMethodAnnotations($reflector); + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Consumes::class, ReflectionAttribute::IS_INSTANCEOF); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + foreach ($annotation->values as $value) { + $descriptor->consumes[] = $value; + } + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Produces::class, ReflectionAttribute::IS_INSTANCEOF); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + foreach ($annotation->values as $value) { + $descriptor->produces[] = $value; + } + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(SerializableResponse::class, ReflectionAttribute::IS_INSTANCEOF); + if (isset($annotations[0])) { + $annotation = $annotations[0]->newInstance(); + foreach ($annotation->getMediaTypes() as $mediaType) { + $descriptor->produces[] = $mediaType; + } + } - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - $result[] = $annotation; - } + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Tag::class); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + foreach ($annotation->values as $value) { + $descriptor->tags[] = $value; } } - return $result; + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Summary::class); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + $descriptor->summary .= $annotation->value; + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Description::class); + foreach ($annotations as $annotation) { + $annotation = $annotation->newInstance(); + $descriptor->description .= $annotation->value; + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Deprecated::class); + if (isset($annotations[0])) { + $descriptor->isDeprecated = true; + } + + /** @var list> $annotations */ + $annotations = $classOrMethod->getAttributes(Priority::class); + if (isset($annotations[0])) { + $annotation = $annotations[0]->newInstance(); + $descriptor->priority = $annotation->value; + } } /** - * Scans the given directory and returns the found classes - * - * @param string $directory - * - * @return class-string[] + * @throws InvalidArgumentException */ - private function scandir(string $directory) : array + private static function completeDescriptor(Descriptor $descriptor): void { - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory) - ); - - $declared = get_declared_classes(); - - foreach ($files as $file) { - if ('php' === $file->getExtension()) { - /** - * @psalm-suppress UnresolvableInclude - */ - require_once $file->getPathname(); - } - } + $descriptor->name = implode($descriptor->namePrefixes) . $descriptor->name; + $descriptor->path = implode($descriptor->pathPrefixes) . $descriptor->path; + + $descriptor->methods = array_map(strtoupper(...), $descriptor->methods); - return array_diff(get_declared_classes(), $declared); + $descriptor->pattern = RouteCompiler::compileRoute($descriptor->path, $descriptor->patterns); } } diff --git a/src/Loader/CollectableFileLoader.php b/src/Loader/DescriptorLoaderInterface.php similarity index 50% rename from src/Loader/CollectableFileLoader.php rename to src/Loader/DescriptorLoaderInterface.php index e1b22879..13dd5e89 100644 --- a/src/Loader/CollectableFileLoader.php +++ b/src/Loader/DescriptorLoaderInterface.php @@ -1,21 +1,22 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Loader; /** - * CollectableFileLoader - * - * @deprecated 2.10.0 Use the ConfigLoader class. + * @since 3.0.0 */ -class CollectableFileLoader extends ConfigLoader +interface DescriptorLoaderInterface extends LoaderInterface { + public function clearCache(): void; } diff --git a/src/Loader/LoaderInterface.php b/src/Loader/LoaderInterface.php index 750a4aa4..fc2086d5 100644 --- a/src/Loader/LoaderInterface.php +++ b/src/Loader/LoaderInterface.php @@ -1,56 +1,27 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Loader; -/** - * Import classes - */ -use Sunrise\Http\Router\Exception\InvalidLoaderResourceException; -use Sunrise\Http\Router\RouteCollectionInterface; +use Sunrise\Http\Router\RouteInterface; /** - * LoaderInterface + * @since 2.0.0 */ interface LoaderInterface { - - /** - * Attaches the given resource to the loader - * - * @param mixed $resource - * - * @return void - * - * @throws InvalidLoaderResourceException - * If the given resource isn't valid. - */ - public function attach($resource) : void; - - /** - * Attaches the given resources to the loader - * - * @param array $resources - * - * @return void - * - * @throws InvalidLoaderResourceException - * If one of the given resources isn't valid. - */ - public function attachArray(array $resources) : void; - /** - * Loads routes from previously attached resources - * - * @return RouteCollectionInterface + * @return iterable */ - public function load() : RouteCollectionInterface; + public function load(): iterable; } diff --git a/src/Middleware/CallableMiddleware.php b/src/Middleware/CallableMiddleware.php index ab2bef18..e6c65b06 100644 --- a/src/Middleware/CallableMiddleware.php +++ b/src/Middleware/CallableMiddleware.php @@ -1,43 +1,37 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Middleware; -/** - * Import classes - */ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; /** - * CallableMiddleware - * * @since 2.8.0 */ -class CallableMiddleware implements MiddlewareInterface +final class CallableMiddleware implements MiddlewareInterface { - /** - * The middleware callback + * @var callable(ServerRequestInterface, RequestHandlerInterface): ResponseInterface * - * @var callable + * @readonly */ private $callback; /** - * Constructor of the class - * - * @param callable $callback + * @param callable(ServerRequestInterface, RequestHandlerInterface): ResponseInterface $callback */ public function __construct(callable $callback) { @@ -45,21 +39,9 @@ public function __construct(callable $callback) } /** - * Gets the middleware callback - * - * @return callable - * - * @since 2.10.0 - */ - public function getCallback() : callable - { - return $this->callback; - } - - /** - * {@inheritdoc} + * @inheritDoc */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { return ($this->callback)($request, $handler); } diff --git a/src/Middleware/JsonPayloadDecodingMiddleware.php b/src/Middleware/JsonPayloadDecodingMiddleware.php index b758dce4..b8642e66 100644 --- a/src/Middleware/JsonPayloadDecodingMiddleware.php +++ b/src/Middleware/JsonPayloadDecodingMiddleware.php @@ -1,137 +1,94 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\Middleware; -/** - * Import classes - */ +use JsonException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Sunrise\Http\Router\Exception\UndecodablePayloadException; +use Sunrise\Http\Router\Dictionary\MediaType; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\ServerRequest; -/** - * Import functions - */ +use function is_array; use function json_decode; -use function json_last_error; -use function json_last_error_msg; -use function rtrim; -use function strpos; -use function substr; -/** - * Import constants - */ use const JSON_BIGINT_AS_STRING; -use const JSON_ERROR_NONE; +use const JSON_THROW_ON_ERROR; /** - * JsonPayloadDecodingMiddleware + * JSON payload decoding middleware * * @since 2.15.0 */ -class JsonPayloadDecodingMiddleware implements MiddlewareInterface +final class JsonPayloadDecodingMiddleware implements MiddlewareInterface { + private const DEFAULT_DECODING_FLAGS = JSON_BIGINT_AS_STRING; + private const DEFAULT_DECODING_DEPTH = 512; /** - * JSON Media Type - * - * @var string - * - * @link https://datatracker.ietf.org/doc/html/rfc4627 + * @since 3.0.0 */ - private const JSON_MEDIA_TYPE = 'application/json'; + public function __construct( + private readonly ?int $decodingFlags = null, + private readonly ?int $decodingDepth = null, + private readonly ?int $errorStatusCode = null, + private readonly ?string $errorMessage = null, + ) { + } /** - * JSON decoding options + * @inheritDoc * - * @var int - * - * @link https://www.php.net/manual/ru/json.constants.php - */ - protected const JSON_DECODING_OPTIONS = JSON_BIGINT_AS_STRING; - - /** - * {@inheritdoc} + * @throws HttpException If the request's payload couldn't be decoded. */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - if (!$this->isSupportedRequest($request)) { - return $handler->handle($request); + if (ServerRequest::create($request)->clientProducesMediaType(MediaType::JSON)) { + $request = $request->withParsedBody($this->decodeJson((string) $request->getBody())); } - $parsedBody = $this->decodeRequestJsonPayload($request); - - return $handler->handle($request->withParsedBody($parsedBody)); - } - - /** - * Checks if the given request is supported - * - * @param ServerRequestInterface $request - * - * @return bool - */ - private function isSupportedRequest(ServerRequestInterface $request) : bool - { - return self::JSON_MEDIA_TYPE === $this->getRequestMediaType($request); + return $handler->handle($request); } /** - * Gets Media Type from the given request - * - * @param ServerRequestInterface $request - * - * @return string|null + * @return array * - * @link https://tools.ietf.org/html/rfc7231#section-3.1.1.1 + * @throws HttpException If the JSON couldn't be decoded. */ - private function getRequestMediaType(ServerRequestInterface $request) : ?string + private function decodeJson(string $json): array { - if (!$request->hasHeader('Content-Type')) { - return null; + if ($json === '') { + throw HttpExceptionFactory::emptyJsonPayload($this->errorMessage, $this->errorStatusCode); } - // type "/" subtype *( OWS ";" OWS parameter ) - $mediaType = $request->getHeaderLine('Content-Type'); + $decodingFlags = $this->decodingFlags ?? self::DEFAULT_DECODING_FLAGS; + /** @psalm-var int<1, 2147483647> $decodingDepth */ + $decodingDepth = $this->decodingDepth ?? self::DEFAULT_DECODING_DEPTH; - $semicolonPosition = strpos($mediaType, ';'); - if (false === $semicolonPosition) { - return $mediaType; + try { + $data = json_decode($json, true, $decodingDepth, $decodingFlags | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw HttpExceptionFactory::invalidJsonPayload($this->errorMessage, $this->errorStatusCode, previous: $e); } - return rtrim(substr($mediaType, 0, $semicolonPosition)); - } - - /** - * Tries to decode the given request's JSON payload - * - * @param ServerRequestInterface $request - * - * @return mixed - * - * @throws UndecodablePayloadException - * If the request's payload cannot be decoded. - */ - private function decodeRequestJsonPayload(ServerRequestInterface $request) - { - json_decode(''); - $result = json_decode($request->getBody()->__toString(), true, 512, static::JSON_DECODING_OPTIONS); - if (JSON_ERROR_NONE === json_last_error()) { - return $result; + if (!is_array($data)) { + throw HttpExceptionFactory::invalidJsonPayloadFormat($this->errorMessage, $this->errorStatusCode); } - throw new UndecodablePayloadException(sprintf('Invalid Payload: %s', json_last_error_msg())); + return $data; } } diff --git a/src/Middleware/PayloadMediaTypeNegotiationMiddleware.php b/src/Middleware/PayloadMediaTypeNegotiationMiddleware.php new file mode 100644 index 00000000..3da295b3 --- /dev/null +++ b/src/Middleware/PayloadMediaTypeNegotiationMiddleware.php @@ -0,0 +1,74 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Sunrise\Http\Router\Dictionary\HeaderName; +use Sunrise\Http\Router\Dictionary\PlaceholderCode; +use Sunrise\Http\Router\Entity\MediaType\StringableMediaType; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\ServerRequest; + +use function array_map; +use function array_values; + +/** + * @since 3.0.0 + */ +final class PayloadMediaTypeNegotiationMiddleware implements MiddlewareInterface +{ + public function __construct( + private readonly ?int $errorStatusCode = null, + private readonly ?string $errorMessage = null, + ) { + } + + /** + * @inheritDoc + * + * @throws HttpException If the request payload's media type isn't supported by the server. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $serverRequest = ServerRequest::create($request); + $serverConsumedMediaTypes = array_values($serverRequest->getRoute()->getConsumedMediaTypes()); + if ($serverConsumedMediaTypes === []) { + return $handler->handle($request); + } + + $clientProducedMediaType = $serverRequest->getClientProducedMediaType(); + if ($clientProducedMediaType === null) { + throw HttpExceptionFactory::missingContentType($this->errorMessage, $this->errorStatusCode) + ->addHeaderField(HeaderName::ACCEPT, ...array_map( + StringableMediaType::create(...), + $serverConsumedMediaTypes, + )); + } + + if (!$serverRequest->clientProducesMediaType(...$serverConsumedMediaTypes)) { + throw HttpExceptionFactory::unsupportedMediaType($this->errorMessage, $this->errorStatusCode) + ->addMessagePlaceholder(PlaceholderCode::MEDIA_TYPE, $clientProducedMediaType->getIdentifier()) + ->addHeaderField(HeaderName::ACCEPT, ...array_map( + StringableMediaType::create(...), + $serverConsumedMediaTypes, + )); + } + + return $handler->handle($request); + } +} diff --git a/src/Middleware/WhitespaceTrimmingMiddleware.php b/src/Middleware/WhitespaceTrimmingMiddleware.php new file mode 100644 index 00000000..e40005f4 --- /dev/null +++ b/src/Middleware/WhitespaceTrimmingMiddleware.php @@ -0,0 +1,57 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +use function array_walk_recursive; +use function is_array; +use function is_string; +use function trim; + +/** + * @since 3.0.0 + */ +final class WhitespaceTrimmingMiddleware implements MiddlewareInterface +{ + /** + * @inheritDoc + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $queryParams = $request->getQueryParams(); + if ($queryParams !== []) { + array_walk_recursive($queryParams, self::trim(...)); + $request = $request->withQueryParams($queryParams); + } + + $parsedBody = $request->getParsedBody(); + if ($parsedBody !== [] && is_array($parsedBody)) { + array_walk_recursive($parsedBody, self::trim(...)); + $request = $request->withParsedBody($parsedBody); + } + + return $handler->handle($request); + } + + private static function trim(mixed &$value): void + { + if (is_string($value)) { + $value = trim($value); + } + } +} diff --git a/src/MiddlewareResolver.php b/src/MiddlewareResolver.php new file mode 100644 index 00000000..51e30fa3 --- /dev/null +++ b/src/MiddlewareResolver.php @@ -0,0 +1,113 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Closure; +use InvalidArgumentException; +use LogicException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use ReflectionException; +use ReflectionFunction; +use ReflectionMethod; +use Sunrise\Http\Router\Middleware\CallableMiddleware; +use Sunrise\Http\Router\ParameterResolver\DirectObjectInjectionParameterResolver; + +use function is_array; +use function is_callable; +use function is_string; +use function is_subclass_of; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class MiddlewareResolver implements MiddlewareResolverInterface +{ + public function __construct( + private readonly ClassResolverInterface $classResolver, + private readonly ParameterResolverChainInterface $parameterResolverChain, + private readonly ResponseResolverChainInterface $responseResolverChain, + ) { + } + + /** + * @inheritDoc + * + * @throws InvalidArgumentException + * @throws LogicException + * @throws ReflectionException + */ + public function resolveMiddleware(mixed $reference): MiddlewareInterface + { + if ($reference instanceof MiddlewareInterface) { + return $reference; + } + + if ($reference instanceof Closure) { + return $this->resolveCallback($reference, new ReflectionFunction($reference)); + } + + if (is_string($reference) && is_subclass_of($reference, MiddlewareInterface::class)) { + return $this->classResolver->resolveClass($reference); + } + + // https://github.com/php/php-src/blob/3ed526441400060aa4e618b91b3352371fcd02a8/Zend/zend_API.c#L3884-L3932 + if (is_array($reference) && is_callable($reference, true)) { + /** @var array{0: class-string|object, 1: string} $reference */ + + if (is_string($reference[0])) { + $reference[0] = $this->classResolver->resolveClass($reference[0]); + } + + if (is_callable($reference)) { + return $this->resolveCallback($reference, new ReflectionMethod($reference[0], $reference[1])); + } + } + + throw new InvalidArgumentException(sprintf( + 'The middleware reference %s could not be resolved.', + ReferenceResolver::stringifyReference($reference), + )); + } + + /** + * @throws InvalidArgumentException + * @throws LogicException + */ + private function resolveCallback( + callable $callback, + ReflectionMethod|ReflectionFunction $reflection, + ): MiddlewareInterface { + return new CallableMiddleware( + fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface => ( + $this->responseResolverChain->resolveResponse( + $callback( + ...$this->parameterResolverChain + ->withContext($request) + ->withResolver( + new DirectObjectInjectionParameterResolver($request), + new DirectObjectInjectionParameterResolver($handler), + ) + ->resolveParameters(...$reflection->getParameters()) + ), + $reflection, + $request, + ) + ) + ); + } +} diff --git a/src/MiddlewareResolverInterface.php b/src/MiddlewareResolverInterface.php new file mode 100644 index 00000000..52fe9408 --- /dev/null +++ b/src/MiddlewareResolverInterface.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use Psr\Http\Server\MiddlewareInterface; + +/** + * @since 3.0.0 + */ +interface MiddlewareResolverInterface +{ + /** + * @throws InvalidArgumentException + */ + public function resolveMiddleware(mixed $reference): MiddlewareInterface; +} diff --git a/src/ParameterResolver/ContainerObjectInjectionParameterResolver.php b/src/ParameterResolver/ContainerObjectInjectionParameterResolver.php new file mode 100644 index 00000000..ff505811 --- /dev/null +++ b/src/ParameterResolver/ContainerObjectInjectionParameterResolver.php @@ -0,0 +1,55 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use ReflectionNamedType; +use ReflectionParameter; +use Sunrise\Http\Router\ParameterResolverInterface; + +/** + * @since 3.0.0 + */ +final class ContainerObjectInjectionParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + /** + * @inheritDoc + * + * @throws ContainerExceptionInterface + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + $type = $parameter->getType(); + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + return; + } + + $typeName = $type->getName(); + if ($this->container->has($typeName)) { + yield $this->container->get($typeName); + } + } + + public function getWeight(): int + { + return -100; + } +} diff --git a/src/ParameterResolver/DefaultValueParameterResolver.php b/src/ParameterResolver/DefaultValueParameterResolver.php new file mode 100644 index 00000000..5542ee2c --- /dev/null +++ b/src/ParameterResolver/DefaultValueParameterResolver.php @@ -0,0 +1,39 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use ReflectionParameter; +use Sunrise\Http\Router\ParameterResolverInterface; + +/** + * @since 3.0.0 + */ +final class DefaultValueParameterResolver implements ParameterResolverInterface +{ + /** + * @inheritDoc + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + if ($parameter->isDefaultValueAvailable()) { + yield $parameter->getDefaultValue(); + } + } + + public function getWeight(): int + { + return -1000; + } +} diff --git a/src/ParameterResolver/DirectObjectInjectionParameterResolver.php b/src/ParameterResolver/DirectObjectInjectionParameterResolver.php new file mode 100644 index 00000000..28e308eb --- /dev/null +++ b/src/ParameterResolver/DirectObjectInjectionParameterResolver.php @@ -0,0 +1,52 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use ReflectionNamedType; +use ReflectionParameter; +use Sunrise\Http\Router\ParameterResolverInterface; + +use function is_a; + +/** + * @since 3.0.0 + */ +final class DirectObjectInjectionParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly object $object, + ) { + } + + /** + * @inheritDoc + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + $type = $parameter->getType(); + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + return; + } + + if (is_a($this->object, $type->getName())) { + yield $this->object; + } + } + + public function getWeight(): int + { + return 100; + } +} diff --git a/src/ParameterResolver/RequestBodyParameterResolver.php b/src/ParameterResolver/RequestBodyParameterResolver.php new file mode 100644 index 00000000..e54b6864 --- /dev/null +++ b/src/ParameterResolver/RequestBodyParameterResolver.php @@ -0,0 +1,115 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use InvalidArgumentException; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionNamedType; +use ReflectionParameter; +use Sunrise\Http\Router\Annotation\RequestBody; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\ParameterResolverChain; +use Sunrise\Http\Router\ParameterResolverInterface; +use Sunrise\Http\Router\Validation\ConstraintViolation\HydratorConstraintViolationAdapter; +use Sunrise\Http\Router\Validation\ConstraintViolation\ValidatorConstraintViolationAdapter; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidObjectException; +use Sunrise\Hydrator\HydratorInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use function array_map; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class RequestBodyParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly HydratorInterface $hydrator, + private readonly ?ValidatorInterface $validator = null, + private readonly ?int $defaultErrorStatusCode = null, + private readonly ?string $defaultErrorMessage = null, + private readonly bool $defaultValidationEnabled = true, + ) { + } + + /** + * @inheritDoc + * + * @throws HttpException + * @throws InvalidArgumentException + * @throws InvalidObjectException + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + if (! $context instanceof ServerRequestInterface) { + return; + } + + /** @var list> $annotations */ + $annotations = $parameter->getAttributes(RequestBody::class); + if ($annotations === []) { + return; + } + + $type = $parameter->getType(); + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new InvalidArgumentException(sprintf( + 'To use the #[RequestBody] annotation, the parameter %s must be typed with an object.', + ParameterResolverChain::stringifyParameter($parameter), + )); + } + + /** @var class-string $className */ + $className = $type->getName(); + $processParams = $annotations[0]->newInstance(); + + $errorStatusCode = $processParams->errorStatusCode ?? $this->defaultErrorStatusCode; + $errorMessage = $processParams->errorMessage ?? $this->defaultErrorMessage; + + try { + $argument = $this->hydrator->hydrate($className, (array) $context->getParsedBody()); + } catch (InvalidDataException $e) { + throw HttpExceptionFactory::invalidBody($errorMessage, $errorStatusCode, previous: $e) + ->addConstraintViolation(...array_map( + HydratorConstraintViolationAdapter::create(...), + $e->getExceptions(), + )); + } + + $validationEnabled = $processParams->validationEnabled ?? $this->defaultValidationEnabled; + + if ($this->validator !== null && $validationEnabled) { + $violations = $this->validator->validate($argument); + if ($violations->count() > 0) { + throw HttpExceptionFactory::invalidBody($errorMessage, $errorStatusCode) + ->addConstraintViolation(...array_map( + ValidatorConstraintViolationAdapter::create(...), + [...$violations], + )); + } + } + + yield $argument; + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ParameterResolver/RequestCookieParameterResolver.php b/src/ParameterResolver/RequestCookieParameterResolver.php new file mode 100644 index 00000000..0599f3ed --- /dev/null +++ b/src/ParameterResolver/RequestCookieParameterResolver.php @@ -0,0 +1,131 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionParameter; +use Sunrise\Http\Router\Annotation\RequestCookie; +use Sunrise\Http\Router\Dictionary\PlaceholderCode; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\ParameterResolverInterface; +use Sunrise\Http\Router\ServerRequest; +use Sunrise\Http\Router\Validation\Constraint\ArgumentConstraint; +use Sunrise\Http\Router\Validation\ConstraintViolation\HydratorConstraintViolationAdapter; +use Sunrise\Http\Router\Validation\ConstraintViolation\ValidatorConstraintViolationAdapter; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidObjectException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\HydratorInterface; +use Sunrise\Hydrator\Type; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use function array_map; + +/** + * @since 3.0.0 + */ +final class RequestCookieParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly HydratorInterface $hydrator, + private readonly ?ValidatorInterface $validator = null, + private readonly ?int $defaultErrorStatusCode = null, + private readonly ?string $defaultErrorMessage = null, + private readonly bool $defaultValidationEnabled = true, + ) { + } + + /** + * @inheritDoc + * + * @throws HttpException + * @throws InvalidObjectException + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + if (! $context instanceof ServerRequestInterface) { + return; + } + + /** @var list> $annotations */ + $annotations = $parameter->getAttributes(RequestCookie::class); + if ($annotations === []) { + return; + } + + $request = ServerRequest::create($context); + $processParams = $annotations[0]->newInstance(); + + $cookieName = $processParams->name; + $errorStatusCode = $processParams->errorStatusCode ?? $this->defaultErrorStatusCode; + $errorMessage = $processParams->errorMessage ?? $this->defaultErrorMessage; + + if (!$request->hasCookieParam($cookieName)) { + if ($parameter->isDefaultValueAvailable()) { + return yield $parameter->getDefaultValue(); + } + + throw HttpExceptionFactory::missingCookie($errorMessage, $errorStatusCode) + ->addMessagePlaceholder(PlaceholderCode::COOKIE_NAME, $cookieName); + } + + try { + $argument = $this->hydrator->castValue( + $request->getCookieParam($cookieName), + Type::fromParameter($parameter), + path: [$cookieName], + ); + } catch (InvalidValueException $e) { + throw HttpExceptionFactory::invalidCookie($errorMessage, $errorStatusCode, previous: $e) + ->addMessagePlaceholder(PlaceholderCode::COOKIE_NAME, $cookieName) + ->addConstraintViolation(new HydratorConstraintViolationAdapter($e)); + } catch (InvalidDataException $e) { + throw HttpExceptionFactory::invalidCookie($errorMessage, $errorStatusCode, previous: $e) + ->addMessagePlaceholder(PlaceholderCode::COOKIE_NAME, $cookieName) + ->addConstraintViolation(...array_map( + HydratorConstraintViolationAdapter::create(...), + $e->getExceptions(), + )); + } + + $validationEnabled = $processParams->validationEnabled ?? $this->defaultValidationEnabled; + + if ($this->validator !== null && $validationEnabled) { + $violations = $this->validator + ->startContext() + ->atPath($cookieName) + ->validate($argument, new ArgumentConstraint($parameter)) + ->getViolations(); + + if ($violations->count() > 0) { + throw HttpExceptionFactory::invalidCookie($errorMessage, $errorStatusCode) + ->addMessagePlaceholder(PlaceholderCode::COOKIE_NAME, $cookieName) + ->addConstraintViolation(...array_map( + ValidatorConstraintViolationAdapter::create(...), + [...$violations], + )); + } + } + + yield $argument; + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ParameterResolver/RequestHeaderParameterResolver.php b/src/ParameterResolver/RequestHeaderParameterResolver.php new file mode 100644 index 00000000..ede1ce6b --- /dev/null +++ b/src/ParameterResolver/RequestHeaderParameterResolver.php @@ -0,0 +1,129 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionParameter; +use Sunrise\Http\Router\Annotation\RequestHeader; +use Sunrise\Http\Router\Dictionary\PlaceholderCode; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\ParameterResolverInterface; +use Sunrise\Http\Router\Validation\Constraint\ArgumentConstraint; +use Sunrise\Http\Router\Validation\ConstraintViolation\HydratorConstraintViolationAdapter; +use Sunrise\Http\Router\Validation\ConstraintViolation\ValidatorConstraintViolationAdapter; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidObjectException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\HydratorInterface; +use Sunrise\Hydrator\Type; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use function array_map; + +/** + * @since 3.0.0 + */ +final class RequestHeaderParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly HydratorInterface $hydrator, + private readonly ?ValidatorInterface $validator = null, + private readonly ?int $defaultErrorStatusCode = null, + private readonly ?string $defaultErrorMessage = null, + private readonly bool $defaultValidationEnabled = true, + ) { + } + + /** + * @inheritDoc + * + * @throws HttpException + * @throws InvalidObjectException + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + if (! $context instanceof ServerRequestInterface) { + return; + } + + /** @var list> $annotations */ + $annotations = $parameter->getAttributes(RequestHeader::class); + if ($annotations === []) { + return; + } + + $processParams = $annotations[0]->newInstance(); + + $headerName = $processParams->name; + $errorStatusCode = $processParams->errorStatusCode ?? $this->defaultErrorStatusCode; + $errorMessage = $processParams->errorMessage ?? $this->defaultErrorMessage; + + if (!$context->hasHeader($headerName)) { + if ($parameter->isDefaultValueAvailable()) { + return yield $parameter->getDefaultValue(); + } + + throw HttpExceptionFactory::missingHeader($errorMessage, $errorStatusCode) + ->addMessagePlaceholder(PlaceholderCode::HEADER_NAME, $headerName); + } + + try { + $argument = $this->hydrator->castValue( + $context->getHeaderLine($headerName), + Type::fromParameter($parameter), + path: [$headerName], + ); + } catch (InvalidValueException $e) { + throw HttpExceptionFactory::invalidHeader($errorMessage, $errorStatusCode, previous: $e) + ->addMessagePlaceholder(PlaceholderCode::HEADER_NAME, $headerName) + ->addConstraintViolation(new HydratorConstraintViolationAdapter($e)); + } catch (InvalidDataException $e) { + throw HttpExceptionFactory::invalidHeader($errorMessage, $errorStatusCode, previous: $e) + ->addMessagePlaceholder(PlaceholderCode::HEADER_NAME, $headerName) + ->addConstraintViolation(...array_map( + HydratorConstraintViolationAdapter::create(...), + $e->getExceptions(), + )); + } + + $validationEnabled = $processParams->validationEnabled ?? $this->defaultValidationEnabled; + + if ($this->validator !== null && $validationEnabled) { + $violations = $this->validator + ->startContext() + ->atPath($headerName) + ->validate($argument, new ArgumentConstraint($parameter)) + ->getViolations(); + + if ($violations->count() > 0) { + throw HttpExceptionFactory::invalidHeader($errorMessage, $errorStatusCode) + ->addMessagePlaceholder(PlaceholderCode::HEADER_NAME, $headerName) + ->addConstraintViolation(...array_map( + ValidatorConstraintViolationAdapter::create(...), + [...$violations], + )); + } + } + + yield $argument; + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ParameterResolver/RequestQueryParameterResolver.php b/src/ParameterResolver/RequestQueryParameterResolver.php new file mode 100644 index 00000000..2dfc94c5 --- /dev/null +++ b/src/ParameterResolver/RequestQueryParameterResolver.php @@ -0,0 +1,115 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use InvalidArgumentException; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionNamedType; +use ReflectionParameter; +use Sunrise\Http\Router\Annotation\RequestQuery; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\ParameterResolverChain; +use Sunrise\Http\Router\ParameterResolverInterface; +use Sunrise\Http\Router\Validation\ConstraintViolation\HydratorConstraintViolationAdapter; +use Sunrise\Http\Router\Validation\ConstraintViolation\ValidatorConstraintViolationAdapter; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidObjectException; +use Sunrise\Hydrator\HydratorInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use function array_map; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class RequestQueryParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly HydratorInterface $hydrator, + private readonly ?ValidatorInterface $validator = null, + private readonly ?int $defaultErrorStatusCode = null, + private readonly ?string $defaultErrorMessage = null, + private readonly bool $defaultValidationEnabled = true, + ) { + } + + /** + * @inheritDoc + * + * @throws HttpException + * @throws InvalidArgumentException + * @throws InvalidObjectException + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + if (! $context instanceof ServerRequestInterface) { + return; + } + + /** @var list> $annotations */ + $annotations = $parameter->getAttributes(RequestQuery::class); + if ($annotations === []) { + return; + } + + $type = $parameter->getType(); + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new InvalidArgumentException(sprintf( + 'To use the #[RequestQuery] annotation, the parameter %s must be typed with an object.', + ParameterResolverChain::stringifyParameter($parameter), + )); + } + + /** @var class-string $className */ + $className = $type->getName(); + $processParams = $annotations[0]->newInstance(); + + $errorStatusCode = $processParams->errorStatusCode ?? $this->defaultErrorStatusCode; + $errorMessage = $processParams->errorMessage ?? $this->defaultErrorMessage; + + try { + $argument = $this->hydrator->hydrate($className, $context->getQueryParams()); + } catch (InvalidDataException $e) { + throw HttpExceptionFactory::invalidQuery($errorMessage, $errorStatusCode, previous: $e) + ->addConstraintViolation(...array_map( + HydratorConstraintViolationAdapter::create(...), + $e->getExceptions(), + )); + } + + $validationEnabled = $processParams->validationEnabled ?? $this->defaultValidationEnabled; + + if ($this->validator !== null && $validationEnabled) { + $violations = $this->validator->validate($argument); + if ($violations->count() > 0) { + throw HttpExceptionFactory::invalidQuery($errorMessage, $errorStatusCode) + ->addConstraintViolation(...array_map( + ValidatorConstraintViolationAdapter::create(...), + [...$violations], + )); + } + } + + yield $argument; + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ParameterResolver/RequestVariableParameterResolver.php b/src/ParameterResolver/RequestVariableParameterResolver.php new file mode 100644 index 00000000..857ef687 --- /dev/null +++ b/src/ParameterResolver/RequestVariableParameterResolver.php @@ -0,0 +1,146 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ParameterResolver; + +use Generator; +use InvalidArgumentException; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionParameter; +use Sunrise\Http\Router\Annotation\RequestVariable; +use Sunrise\Http\Router\Dictionary\PlaceholderCode; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\Helper\RouteSimplifier; +use Sunrise\Http\Router\ParameterResolverChain; +use Sunrise\Http\Router\ParameterResolverInterface; +use Sunrise\Http\Router\ServerRequest; +use Sunrise\Http\Router\Validation\Constraint\ArgumentConstraint; +use Sunrise\Http\Router\Validation\ConstraintViolation\HydratorConstraintViolationAdapter; +use Sunrise\Http\Router\Validation\ConstraintViolation\ValidatorConstraintViolationAdapter; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidObjectException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\HydratorInterface; +use Sunrise\Hydrator\Type; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use function array_map; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class RequestVariableParameterResolver implements ParameterResolverInterface +{ + public function __construct( + private readonly HydratorInterface $hydrator, + private readonly ?ValidatorInterface $validator = null, + private readonly ?int $defaultErrorStatusCode = null, + private readonly ?string $defaultErrorMessage = null, + private readonly bool $defaultValidationEnabled = true, + ) { + } + + /** + * @inheritDoc + * + * @throws HttpException + * @throws InvalidArgumentException + * @throws InvalidObjectException + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator + { + if (! $context instanceof ServerRequestInterface) { + return; + } + + /** @var list> $annotations */ + $annotations = $parameter->getAttributes(RequestVariable::class); + if ($annotations === []) { + return; + } + + $route = ServerRequest::create($context)->getRoute(); + $processParams = $annotations[0]->newInstance(); + + $variableName = $processParams->name ?? $parameter->getName(); + $errorStatusCode = $processParams->errorStatusCode ?? $this->defaultErrorStatusCode; + $errorMessage = $processParams->errorMessage ?? $this->defaultErrorMessage; + + if (!$route->hasAttribute($variableName)) { + if ($parameter->isDefaultValueAvailable()) { + return yield $parameter->getDefaultValue(); + } + + throw new InvalidArgumentException(sprintf( + 'The parameter %s expects a value of the variable {%s} from the route %s, ' . + 'which is not present in the request, likely because the variable is optional. ' . + 'To resolve this issue, assign the default value to the parameter.', + ParameterResolverChain::stringifyParameter($parameter), + $variableName, + $route->getName(), + )); + } + + try { + $argument = $this->hydrator->castValue( + $route->getAttribute($variableName), + Type::fromParameter($parameter), + path: [$variableName], + ); + } catch (InvalidValueException $e) { + throw HttpExceptionFactory::invalidVariable($errorMessage, $errorStatusCode, previous: $e) + ->addMessagePlaceholder(PlaceholderCode::VARIABLE_NAME, $variableName) + ->addMessagePlaceholder(PlaceholderCode::ROUTE_URI, RouteSimplifier::simplifyRoute($route->getPath())) + ->addConstraintViolation(new HydratorConstraintViolationAdapter($e)); + } catch (InvalidDataException $e) { + throw HttpExceptionFactory::invalidVariable($errorMessage, $errorStatusCode, previous: $e) + ->addMessagePlaceholder(PlaceholderCode::VARIABLE_NAME, $variableName) + ->addMessagePlaceholder(PlaceholderCode::ROUTE_URI, RouteSimplifier::simplifyRoute($route->getPath())) + ->addConstraintViolation(...array_map( + HydratorConstraintViolationAdapter::create(...), + $e->getExceptions(), + )); + } + + $validationEnabled = $processParams->validationEnabled ?? $this->defaultValidationEnabled; + + if ($this->validator !== null && $validationEnabled) { + $violations = $this->validator + ->startContext() + ->atPath($variableName) + ->validate($argument, new ArgumentConstraint($parameter)) + ->getViolations(); + + if ($violations->count() > 0) { + throw HttpExceptionFactory::invalidVariable($errorMessage, $errorStatusCode) + ->addMessagePlaceholder(PlaceholderCode::VARIABLE_NAME, $variableName) + // phpcs:ignore Generic.Files.LineLength.TooLong + ->addMessagePlaceholder(PlaceholderCode::ROUTE_URI, RouteSimplifier::simplifyRoute($route->getPath())) + ->addConstraintViolation(...array_map( + ValidatorConstraintViolationAdapter::create(...), + [...$violations], + )); + } + } + + yield $argument; + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ParameterResolverChain.php b/src/ParameterResolverChain.php new file mode 100644 index 00000000..3bc47d92 --- /dev/null +++ b/src/ParameterResolverChain.php @@ -0,0 +1,120 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Generator; +use InvalidArgumentException; +use LogicException; +use ReflectionMethod; +use ReflectionParameter; + +use function sprintf; +use function usort; + +/** + * @since 3.0.0 + */ +final class ParameterResolverChain implements ParameterResolverChainInterface +{ + private mixed $context = null; + + private bool $isSorted = false; + + public function __construct( + /** @var array */ + private array $resolvers = [], + ) { + } + + public function withContext(mixed $context): static + { + $clone = clone $this; + $clone->context = $context; + + return $clone; + } + + public function withResolver(ParameterResolverInterface ...$resolvers): static + { + $clone = clone $this; + $clone->isSorted = false; + foreach ($resolvers as $resolver) { + $clone->resolvers[] = $resolver; + } + + return $clone; + } + + /** + * @inheritDoc + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public function resolveParameters(ReflectionParameter ...$parameters): Generator + { + $this->isSorted or $this->sortResolvers(); + foreach ($parameters as $parameter) { + yield from $this->resolveParameter($parameter); + } + } + + /** + * @return Generator + * + * @throws InvalidArgumentException + * @throws LogicException + */ + private function resolveParameter(ReflectionParameter $parameter): Generator + { + foreach ($this->resolvers as $resolver) { + $arguments = $resolver->resolveParameter($parameter, $this->context); + if ($arguments->valid()) { + return yield from $arguments; + } + } + + throw new LogicException(sprintf( + 'The parameter %s is not supported and cannot be resolved.', + self::stringifyParameter($parameter), + )); + } + + private function sortResolvers(): void + { + $this->isSorted = usort($this->resolvers, self::resolversSorter(...)); + } + + private static function resolversSorter(ParameterResolverInterface $a, ParameterResolverInterface $b): int + { + return $b->getWeight() <=> $a->getWeight(); + } + + public static function stringifyParameter(ReflectionParameter $parameter): string + { + $function = $parameter->getDeclaringFunction(); + + if ($function instanceof ReflectionMethod) { + return sprintf( + '%s::%s($%s[%d])', + $function->getDeclaringClass()->getName(), + $function->getName(), + $parameter->getName(), + $parameter->getPosition(), + ); + } + + return sprintf('%s($%s[%d])', $function->getName(), $parameter->getName(), $parameter->getPosition()); + } +} diff --git a/src/ParameterResolverChainInterface.php b/src/ParameterResolverChainInterface.php new file mode 100644 index 00000000..13737c7d --- /dev/null +++ b/src/ParameterResolverChainInterface.php @@ -0,0 +1,37 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Generator; +use InvalidArgumentException; +use LogicException; +use ReflectionParameter; + +/** + * @since 3.0.0 + */ +interface ParameterResolverChainInterface +{ + public function withContext(mixed $context): static; + + public function withResolver(ParameterResolverInterface ...$resolvers): static; + + /** + * @return Generator + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public function resolveParameters(ReflectionParameter ...$parameters): Generator; +} diff --git a/src/ParameterResolverInterface.php b/src/ParameterResolverInterface.php new file mode 100644 index 00000000..90fd400b --- /dev/null +++ b/src/ParameterResolverInterface.php @@ -0,0 +1,33 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Generator; +use InvalidArgumentException; +use ReflectionParameter; + +/** + * @since 3.0.0 + */ +interface ParameterResolverInterface +{ + /** + * @return Generator + * + * @throws InvalidArgumentException + */ + public function resolveParameter(ReflectionParameter $parameter, mixed $context): Generator; + + public function getWeight(): int; +} diff --git a/src/ReferenceResolver.php b/src/ReferenceResolver.php index b0461b78..cdc529fe 100644 --- a/src/ReferenceResolver.php +++ b/src/ReferenceResolver.php @@ -1,185 +1,88 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router; -/** - * Import classes - */ use Psr\Container\ContainerInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Sunrise\Http\Router\Exception\UnresolvableReferenceException; -use Sunrise\Http\Router\Middleware\CallableMiddleware; -use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; -use Closure; -/** - * Import functions - */ -use function is_array; +use function get_debug_type; use function is_callable; -use function is_string; -use function is_subclass_of; -use function method_exists; -use function sprintf; /** - * ReferenceResolver - * * @since 2.10.0 */ -class ReferenceResolver implements ReferenceResolverInterface +final class ReferenceResolver implements ReferenceResolverInterface { - - /** - * The reference resolver container - * - * @var ContainerInterface|null - */ - private $container = null; - - /** - * {@inheritdoc} - */ - public function getContainer() : ?ContainerInterface - { - return $this->container; - } - - /** - * {@inheritdoc} - */ - public function setContainer(?ContainerInterface $container) : void - { - $this->container = $container; - } - - /** - * {@inheritdoc} - */ - public function toRequestHandler($reference) : RequestHandlerInterface - { - if ($reference instanceof RequestHandlerInterface) { - return $reference; - } - - if ($reference instanceof Closure) { - return new CallableRequestHandler($reference); - } - - list($class, $method) = $this->normalizeReference($reference); - - if (isset($class) && isset($method) && method_exists($class, $method)) { - return new CallableRequestHandler([$this->resolveClass($class), $method]); - } - - if (!isset($method) && isset($class) && is_subclass_of($class, RequestHandlerInterface::class)) { - return $this->resolveClass($class); - } - - throw new UnresolvableReferenceException(sprintf( - 'Unable to resolve the "%s" reference to a request handler.', - $this->stringifyReference($reference) - )); + public function __construct( + private readonly MiddlewareResolverInterface $middlewareResolver, + private readonly RequestHandlerResolverInterface $requestHandlerResolver, + ) { } /** - * {@inheritdoc} + * @param array $parameterResolvers + * @param array $responseResolvers */ - public function toMiddleware($reference) : MiddlewareInterface - { - if ($reference instanceof MiddlewareInterface) { - return $reference; - } - - if ($reference instanceof Closure) { - return new CallableMiddleware($reference); - } - - list($class, $method) = $this->normalizeReference($reference); - - if (isset($class) && isset($method) && method_exists($class, $method)) { - return new CallableMiddleware([$this->resolveClass($class), $method]); - } - - if (!isset($method) && isset($class) && is_subclass_of($class, MiddlewareInterface::class)) { - return $this->resolveClass($class); - } - - throw new UnresolvableReferenceException(sprintf( - 'Unable to resolve the "%s" reference to a middleware.', - $this->stringifyReference($reference) - )); + public static function build( + array $parameterResolvers = [], + array $responseResolvers = [], + ?ContainerInterface $container = null, + ): ReferenceResolverInterface { + $parameterResolverChain = new ParameterResolverChain($parameterResolvers); + $responseResolverChain = new ResponseResolverChain($responseResolvers); + + $classResolver = new ClassResolver($parameterResolverChain, $container); + + $middlewareResolver = new MiddlewareResolver( + $classResolver, + $parameterResolverChain, + $responseResolverChain, + ); + + $requestHandlerResolver = new RequestHandlerResolver( + $classResolver, + $parameterResolverChain, + $responseResolverChain, + ); + + return new self($middlewareResolver, $requestHandlerResolver); } /** - * Normalizes the given reference - * - * @param mixed $reference - * - * @return array{0: ?class-string, 1: ?string} + * @inheritDoc */ - private function normalizeReference($reference) : array + public function resolveMiddleware(mixed $reference): MiddlewareInterface { - if (is_array($reference) && is_callable($reference, true)) { - /** @var array{0: class-string, 1: string} $reference */ - - return $reference; - } - - if (is_string($reference)) { - /** @var class-string $reference */ - - return [$reference, null]; - } - - return [null, null]; + return $this->middlewareResolver->resolveMiddleware($reference); } /** - * Stringifies the given reference - * - * @param mixed $reference - * - * @return string + * @inheritDoc */ - private function stringifyReference($reference) : string + public function resolveRequestHandler(mixed $reference): RequestHandlerInterface { - if (is_array($reference) && is_callable($reference, true)) { - return $reference[0] . '@' . $reference[1]; - } - - if (is_string($reference)) { - return $reference; - } - - return ''; + return $this->requestHandlerResolver->resolveRequestHandler($reference); } - /** - * Resolves the given class - * - * @param class-string $class - * - * @return T - * - * @template T - */ - private function resolveClass(string $class) + public static function stringifyReference(mixed $reference): string { - if ($this->container && $this->container->has($class)) { - return $this->container->get($class); + // https://github.com/php/php-src/blob/3ed526441400060aa4e618b91b3352371fcd02a8/Zend/zend_API.c#L3884-L3932 + if (is_callable($reference, true, $result)) { + return $result; } - return new $class; + return get_debug_type($reference); } } diff --git a/src/ReferenceResolverInterface.php b/src/ReferenceResolverInterface.php index 219b9360..15760d5b 100644 --- a/src/ReferenceResolverInterface.php +++ b/src/ReferenceResolverInterface.php @@ -1,69 +1,21 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ -namespace Sunrise\Http\Router; +declare(strict_types=1); -/** - * Import classes - */ -use Psr\Container\ContainerInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; -use Sunrise\Http\Router\Exception\UnresolvableReferenceException; +namespace Sunrise\Http\Router; /** - * ReferenceResolverInterface - * * @since 2.10.0 */ -interface ReferenceResolverInterface +interface ReferenceResolverInterface extends MiddlewareResolverInterface, RequestHandlerResolverInterface { - - /** - * Gets the reference resolver container - * - * @return ContainerInterface|null - */ - public function getContainer() : ?ContainerInterface; - - /** - * Sets the given container to the reference resolver - * - * @param ContainerInterface|null $container - * - * @return void - */ - public function setContainer(?ContainerInterface $container) : void; - - /** - * Resolves the given reference to a request handler - * - * @param mixed $reference - * - * @return RequestHandlerInterface - * - * @throws UnresolvableReferenceException - * If the given reference cannot be resolved to a request handler. - */ - public function toRequestHandler($reference) : RequestHandlerInterface; - - /** - * Resolves the given reference to a middleware - * - * @param mixed $reference - * - * @return MiddlewareInterface - * - * @throws UnresolvableReferenceException - * If the given reference cannot be resolved to a middleware. - */ - public function toMiddleware($reference) : MiddlewareInterface; } diff --git a/src/RequestHandler/CallableRequestHandler.php b/src/RequestHandler/CallableRequestHandler.php index d84e313b..c12adf00 100644 --- a/src/RequestHandler/CallableRequestHandler.php +++ b/src/RequestHandler/CallableRequestHandler.php @@ -1,40 +1,36 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\RequestHandler; -/** - * Import classes - */ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; /** - * CallableRequestHandler + * @since 2.0.0 */ -class CallableRequestHandler implements RequestHandlerInterface +final class CallableRequestHandler implements RequestHandlerInterface { - /** - * The request handler callback + * @var callable(ServerRequestInterface): ResponseInterface * - * @var callable + * @readonly */ private $callback; /** - * Constructor of the class - * - * @param callable $callback + * @param callable(ServerRequestInterface): ResponseInterface $callback */ public function __construct(callable $callback) { @@ -42,21 +38,9 @@ public function __construct(callable $callback) } /** - * Gets the request handler callback - * - * @return callable - * - * @since 2.10.0 - */ - public function getCallback() : callable - { - return $this->callback; - } - - /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(ServerRequestInterface $request) : ResponseInterface + public function handle(ServerRequestInterface $request): ResponseInterface { return ($this->callback)($request); } diff --git a/src/RequestHandler/QueueableRequestHandler.php b/src/RequestHandler/QueueableRequestHandler.php index ce9c45f0..9e09c280 100644 --- a/src/RequestHandler/QueueableRequestHandler.php +++ b/src/RequestHandler/QueueableRequestHandler.php @@ -1,19 +1,18 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router\RequestHandler; -/** - * Import classes - */ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -21,59 +20,24 @@ use SplQueue; /** - * QueueableRequestHandler + * @since 2.0.0 + * + * @extends SplQueue */ -class QueueableRequestHandler implements RequestHandlerInterface +final class QueueableRequestHandler extends SplQueue implements RequestHandlerInterface { - - /** - * The request handler queue - * - * @var SplQueue - */ - private $queue; - - /** - * The request handler endpoint - * - * @var RequestHandlerInterface - */ - private $endpoint; - - /** - * Constructor of the class - * - * @param RequestHandlerInterface $endpoint - */ - public function __construct(RequestHandlerInterface $endpoint) - { - $this->queue = new SplQueue(); - $this->endpoint = $endpoint; - } - - /** - * Adds the given middleware(s) to the request handler queue - * - * @param MiddlewareInterface ...$middlewares - * - * @return void - */ - public function add(MiddlewareInterface ...$middlewares) : void - { - foreach ($middlewares as $middleware) { - $this->queue->enqueue($middleware); - } + public function __construct( + private readonly RequestHandlerInterface $endpoint, + ) { } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(ServerRequestInterface $request) : ResponseInterface + public function handle(ServerRequestInterface $request): ResponseInterface { - if (!$this->queue->isEmpty()) { - return $this->queue->dequeue()->process($request, $this); - } - - return $this->endpoint->handle($request); + return $this->isEmpty() + ? $this->endpoint->handle($request) + : ($clone = clone $this)->dequeue()->process($request, $clone); } } diff --git a/src/RequestHandlerReflector.php b/src/RequestHandlerReflector.php new file mode 100644 index 00000000..8812f9af --- /dev/null +++ b/src/RequestHandlerReflector.php @@ -0,0 +1,65 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Closure; +use InvalidArgumentException; +use Psr\Http\Server\RequestHandlerInterface; +use ReflectionClass; +use ReflectionException; +use ReflectionFunction; +use ReflectionMethod; + +use function is_array; +use function is_callable; +use function is_string; +use function is_subclass_of; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class RequestHandlerReflector implements RequestHandlerReflectorInterface +{ + /** + * @inheritDoc + * + * @throws InvalidArgumentException + * @throws ReflectionException + */ + public function reflectRequestHandler(mixed $reference): ReflectionClass|ReflectionMethod|ReflectionFunction + { + if ($reference instanceof RequestHandlerInterface) { + return new ReflectionClass($reference); + } + + if ($reference instanceof Closure) { + return new ReflectionFunction($reference); + } + + if (is_string($reference) && is_subclass_of($reference, RequestHandlerInterface::class)) { + return new ReflectionClass($reference); + } + + // https://github.com/php/php-src/blob/3ed526441400060aa4e618b91b3352371fcd02a8/Zend/zend_API.c#L3884-L3932 + if (is_array($reference) && is_callable($reference, true, $method)) { + return new ReflectionMethod($method); + } + + throw new InvalidArgumentException(sprintf( + 'The request handler reference %s could not be reflected.', + ReferenceResolver::stringifyReference($reference), + )); + } +} diff --git a/src/RequestHandlerReflectorInterface.php b/src/RequestHandlerReflectorInterface.php new file mode 100644 index 00000000..1fe2329f --- /dev/null +++ b/src/RequestHandlerReflectorInterface.php @@ -0,0 +1,32 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use ReflectionClass; +use ReflectionFunction; +use ReflectionMethod; + +/** + * @since 3.0.0 + */ +interface RequestHandlerReflectorInterface +{ + /** + * @return ReflectionClass|ReflectionMethod|ReflectionFunction + * + * @throws InvalidArgumentException + */ + public function reflectRequestHandler(mixed $reference): ReflectionClass|ReflectionMethod|ReflectionFunction; +} diff --git a/src/RequestHandlerResolver.php b/src/RequestHandlerResolver.php new file mode 100644 index 00000000..d9eafb09 --- /dev/null +++ b/src/RequestHandlerResolver.php @@ -0,0 +1,109 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Closure; +use InvalidArgumentException; +use LogicException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use ReflectionException; +use ReflectionFunction; +use ReflectionMethod; +use Sunrise\Http\Router\ParameterResolver\DirectObjectInjectionParameterResolver; +use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; + +use function is_array; +use function is_callable; +use function is_string; +use function is_subclass_of; +use function sprintf; + +/** + * @since 3.0.0 + */ +final class RequestHandlerResolver implements RequestHandlerResolverInterface +{ + public function __construct( + private readonly ClassResolverInterface $classResolver, + private readonly ParameterResolverChainInterface $parameterResolverChain, + private readonly ResponseResolverChainInterface $responseResolverChain, + ) { + } + + /** + * @inheritDoc + * + * @throws InvalidArgumentException + * @throws LogicException + * @throws ReflectionException + */ + public function resolveRequestHandler(mixed $reference): RequestHandlerInterface + { + if ($reference instanceof RequestHandlerInterface) { + return $reference; + } + + if ($reference instanceof Closure) { + return $this->resolveCallback($reference, new ReflectionFunction($reference)); + } + + if (is_string($reference) && is_subclass_of($reference, RequestHandlerInterface::class)) { + return $this->classResolver->resolveClass($reference); + } + + // https://github.com/php/php-src/blob/3ed526441400060aa4e618b91b3352371fcd02a8/Zend/zend_API.c#L3884-L3932 + if (is_array($reference) && is_callable($reference, true)) { + /** @var array{0: class-string|object, 1: string} $reference */ + + if (is_string($reference[0])) { + $reference[0] = $this->classResolver->resolveClass($reference[0]); + } + + if (is_callable($reference)) { + return $this->resolveCallback($reference, new ReflectionMethod($reference[0], $reference[1])); + } + } + + throw new InvalidArgumentException(sprintf( + 'The request handler reference %s could not be resolved.', + ReferenceResolver::stringifyReference($reference), + )); + } + + /** + * @throws InvalidArgumentException + * @throws LogicException + */ + private function resolveCallback( + callable $callback, + ReflectionMethod|ReflectionFunction $reflection, + ): RequestHandlerInterface { + return new CallableRequestHandler( + fn(ServerRequestInterface $request): ResponseInterface => ( + $this->responseResolverChain->resolveResponse( + $callback( + ...$this->parameterResolverChain + ->withContext($request) + ->withResolver(new DirectObjectInjectionParameterResolver($request)) + ->resolveParameters(...$reflection->getParameters()) + ), + $reflection, + $request, + ) + ) + ); + } +} diff --git a/src/RequestHandlerResolverInterface.php b/src/RequestHandlerResolverInterface.php new file mode 100644 index 00000000..a2b254d1 --- /dev/null +++ b/src/RequestHandlerResolverInterface.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * @since 3.0.0 + */ +interface RequestHandlerResolverInterface +{ + /** + * @throws InvalidArgumentException + */ + public function resolveRequestHandler(mixed $reference): RequestHandlerInterface; +} diff --git a/src/ResponseResolver/EmptyResponseResolver.php b/src/ResponseResolver/EmptyResponseResolver.php new file mode 100644 index 00000000..68f4a113 --- /dev/null +++ b/src/ResponseResolver/EmptyResponseResolver.php @@ -0,0 +1,59 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ResponseResolver; + +use Fig\Http\Message\StatusCodeInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionFunction; +use ReflectionMethod; +use Sunrise\Http\Router\Annotation\EmptyResponse; +use Sunrise\Http\Router\ResponseResolverInterface; + +/** + * @since 3.0.0 + */ +final class EmptyResponseResolver implements ResponseResolverInterface +{ + private const DEFAULT_STATUS_CODE = StatusCodeInterface::STATUS_NO_CONTENT; + + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly ?int $defaultStatusCode = null, + ) { + } + + /** + * @inheritDoc + */ + public function resolveResponse( + mixed $response, + ReflectionMethod|ReflectionFunction $responder, + ServerRequestInterface $request, + ): ?ResponseInterface { + if ($responder->getAttributes(EmptyResponse::class) === []) { + return null; + } + + $statusCode = $this->defaultStatusCode ?? self::DEFAULT_STATUS_CODE; + + return $this->responseFactory->createResponse($statusCode); + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ResponseResolver/JsonResponseResolver.php b/src/ResponseResolver/JsonResponseResolver.php new file mode 100644 index 00000000..c98b27dc --- /dev/null +++ b/src/ResponseResolver/JsonResponseResolver.php @@ -0,0 +1,100 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\ResponseResolver; + +use Fig\Http\Message\StatusCodeInterface; +use JsonException; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionFunction; +use ReflectionMethod; +use RuntimeException; +use Sunrise\Http\Router\Annotation\JsonResponse; +use Sunrise\Http\Router\Dictionary\HeaderName; +use Sunrise\Http\Router\ResponseResolverChain; +use Sunrise\Http\Router\ResponseResolverInterface; + +use function json_encode; +use function sprintf; + +use const JSON_THROW_ON_ERROR; + +/** + * @since 3.0.0 + */ +final class JsonResponseResolver implements ResponseResolverInterface +{ + private const DEFAULT_ENCODING_FLAGS = 0; + private const DEFAULT_ENCODING_DEPTH = 512; + + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly ?int $defaultEncodingFlags = null, + private readonly ?int $defaultEncodingDepth = null, + ) { + } + + /** + * @inheritDoc + * + * @throws RuntimeException + */ + public function resolveResponse( + mixed $response, + ReflectionMethod|ReflectionFunction $responder, + ServerRequestInterface $request, + ): ?ResponseInterface { + /** @var list> $annotations */ + $annotations = $responder->getAttributes(JsonResponse::class); + if ($annotations === []) { + return null; + } + + $processParams = $annotations[0]->newInstance(); + + $encodingFlags = $processParams->encodingFlags + ?? $this->defaultEncodingFlags + ?? self::DEFAULT_ENCODING_FLAGS; + + /** @psalm-var int<1, 2147483647> $encodingDepth */ + $encodingDepth = $processParams->encodingDepth + ?? $this->defaultEncodingDepth + ?? self::DEFAULT_ENCODING_DEPTH; + + try { + $payload = json_encode($response, $encodingFlags | JSON_THROW_ON_ERROR, $encodingDepth); + } catch (JsonException $e) { + throw new RuntimeException(sprintf( + 'The responder %s returned a response that could not be encoded to JSON due to: %s', + ResponseResolverChain::stringifyResponder($responder), + $e->getMessage(), + ), previous: $e); + } + + $jsonResponse = $this->responseFactory + ->createResponse(StatusCodeInterface::STATUS_OK) + ->withHeader(HeaderName::CONTENT_TYPE, 'application/json; charset=UTF-8'); + + $jsonResponse->getBody()->write($payload); + + return $jsonResponse; + } + + public function getWeight(): int + { + return 0; + } +} diff --git a/src/ResponseResolverChain.php b/src/ResponseResolverChain.php new file mode 100644 index 00000000..5ddbec12 --- /dev/null +++ b/src/ResponseResolverChain.php @@ -0,0 +1,110 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use LogicException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionAttribute; +use ReflectionFunction; +use ReflectionMethod; +use Sunrise\Http\Router\Annotation\ResponseHeader; +use Sunrise\Http\Router\Annotation\ResponseStatus; + +use function sprintf; +use function usort; + +/** + * @since 3.0.0 + */ +final class ResponseResolverChain implements ResponseResolverChainInterface +{ + private bool $isSorted = false; + + public function __construct( + /** @var array */ + private array $resolvers = [], + ) { + } + + /** + * @inheritDoc + * + * @throws InvalidArgumentException + * @throws LogicException + */ + public function resolveResponse( + mixed $response, + ReflectionMethod|ReflectionFunction $responder, + ServerRequestInterface $request, + ): ResponseInterface { + if ($response instanceof ResponseInterface) { + return $response; + } + + $this->isSorted or $this->sortResolvers(); + foreach ($this->resolvers as $resolver) { + $result = $resolver->resolveResponse($response, $responder, $request); + if ($result instanceof ResponseInterface) { + return self::completeResponse($result, $responder); + } + } + + throw new LogicException(sprintf( + 'The responder %s returned an unsupported response that cannot be resolved.', + self::stringifyResponder($responder), + )); + } + + private static function completeResponse( + ResponseInterface $response, + ReflectionMethod|ReflectionFunction $responder, + ): ResponseInterface { + /** @var list> $annotations */ + $annotations = $responder->getAttributes(ResponseStatus::class); + if (isset($annotations[0])) { + $status = $annotations[0]->newInstance(); + $response = $response->withStatus($status->code, $status->phrase); + } + + /** @var list> $annotations */ + $annotations = $responder->getAttributes(ResponseHeader::class); + foreach ($annotations as $annotation) { + $header = $annotation->newInstance(); + $response = $response->withHeader($header->name, $header->value); + } + + return $response; + } + + private function sortResolvers(): void + { + $this->isSorted = usort($this->resolvers, self::resolversSorter(...)); + } + + private static function resolversSorter(ResponseResolverInterface $a, ResponseResolverInterface $b): int + { + return $b->getWeight() <=> $a->getWeight(); + } + + public static function stringifyResponder(ReflectionMethod|ReflectionFunction $responder): string + { + if ($responder instanceof ReflectionMethod) { + return sprintf('%s::%s()', $responder->getDeclaringClass()->getName(), $responder->getName()); + } + + return sprintf('%s()', $responder->getName()); + } +} diff --git a/src/ResponseResolverChainInterface.php b/src/ResponseResolverChainInterface.php new file mode 100644 index 00000000..e7b2b44a --- /dev/null +++ b/src/ResponseResolverChainInterface.php @@ -0,0 +1,37 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use LogicException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionFunction; +use ReflectionMethod; + +/** + * @since 3.0.0 + */ +interface ResponseResolverChainInterface +{ + /** + * @throws InvalidArgumentException + * @throws LogicException + */ + public function resolveResponse( + mixed $response, + ReflectionMethod|ReflectionFunction $responder, + ServerRequestInterface $request, + ): ResponseInterface; +} diff --git a/src/ResponseResolverInterface.php b/src/ResponseResolverInterface.php new file mode 100644 index 00000000..25c2a858 --- /dev/null +++ b/src/ResponseResolverInterface.php @@ -0,0 +1,37 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionFunction; +use ReflectionMethod; + +/** + * @since 3.0.0 + */ +interface ResponseResolverInterface +{ + /** + * @throws InvalidArgumentException + */ + public function resolveResponse( + mixed $response, + ReflectionMethod|ReflectionFunction $responder, + ServerRequestInterface $request, + ): ?ResponseInterface; + + public function getWeight(): int; +} diff --git a/src/Route.php b/src/Route.php index e442795c..2de6bfcd 100644 --- a/src/Route.php +++ b/src/Route.php @@ -1,445 +1,191 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router; -/** - * Import classes - */ -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; -use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; -use Sunrise\Http\Router\RequestHandler\QueueableRequestHandler; -use Closure; -use ReflectionClass; -use ReflectionMethod; -use ReflectionFunction; -use Reflector; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; -/** - * Import functions - */ -use function rtrim; -use function strtoupper; +use function in_array; /** - * Route - * - * Use the {@see RouteFactory} factory to create this class. + * @since 1.0.0 */ -class Route implements RouteInterface +final class Route implements RouteInterface { - - /** - * Server Request attribute name for the route - * - * @var string - * - * @deprecated 2.11.0 Use the RouteInterface::ATTR_ROUTE constant. - */ - public const ATTR_NAME_FOR_ROUTE = self::ATTR_ROUTE; - - /** - * Server Request attribute name for the route name - * - * @var string - * - * @deprecated 2.9.0 - */ - public const ATTR_NAME_FOR_ROUTE_NAME = '@route-name'; - - /** - * The route name - * - * @var string - */ - private $name; - - /** - * The route host - * - * @var string|null - */ - private $host = null; - - /** - * The route path - * - * @var string - */ - private $path; - - /** - * The route methods - * - * @var string[] - */ - private $methods; - - /** - * The route request handler - * - * @var RequestHandlerInterface - */ - private $requestHandler; - - /** - * The route middlewares - * - * @var MiddlewareInterface[] - */ - private $middlewares = []; - - /** - * The route attributes - * - * @var array - */ - private $attributes = []; - - /** - * The route summary - * - * @var string - */ - private $summary = ''; - - /** - * The route description - * - * @var string - */ - private $description = ''; - - /** - * The route tags - * - * @var string[] - */ - private $tags = []; - - /** - * Constructor of the class - * - * @param string $name - * @param string $path - * @param string[] $methods - * @param RequestHandlerInterface $requestHandler - * @param MiddlewareInterface[] $middlewares - * @param array $attributes - */ public function __construct( - string $name, - string $path, - array $methods, - RequestHandlerInterface $requestHandler, - array $middlewares = [], - array $attributes = [] + private readonly string $name, + private readonly string $path, + private readonly mixed $requestHandler, + /** @var array */ + private readonly array $patterns = [], + /** @var array */ + private readonly array $methods = [], + /** @var array */ + private array $attributes = [], + /** @var array */ + private readonly array $middlewares = [], + /** @var array */ + private readonly array $consumes = [], + /** @var array */ + private readonly array $produces = [], + /** @var array */ + private readonly array $tags = [], + private readonly string $summary = '', + private readonly string $description = '', + private readonly bool $isDeprecated = false, + private readonly bool $isApiOperation = false, + /** @var array|object|null */ + private readonly array|object|null $apiOperationFields = null, + /** @var non-empty-string|null */ + private readonly ?string $pattern = null, ) { - $this->setName($name); - $this->setPath($path); - $this->setMethods(...$methods); - $this->setRequestHandler($requestHandler); - $this->setMiddlewares(...$middlewares); - $this->setAttributes($attributes); } - /** - * {@inheritdoc} - */ - public function getName() : string + public function getName(): string { return $this->name; } - /** - * {@inheritdoc} - */ - public function getHost() : ?string - { - return $this->host; - } - - /** - * {@inheritdoc} - */ - public function getPath() : string + public function getPath(): string { return $this->path; } - /** - * {@inheritdoc} - */ - public function getMethods() : array - { - return $this->methods; - } - - /** - * {@inheritdoc} - */ - public function getRequestHandler() : RequestHandlerInterface + public function getRequestHandler(): mixed { return $this->requestHandler; } /** - * {@inheritdoc} - */ - public function getMiddlewares() : array - { - return $this->middlewares; - } - - /** - * {@inheritdoc} + * @inheritDoc */ - public function getAttributes() : array + public function getPatterns(): array { - return $this->attributes; + return $this->patterns; } /** - * {@inheritdoc} + * @inheritDoc */ - public function getSummary() : string + public function getMethods(): array { - return $this->summary; - } - - /** - * {@inheritdoc} - */ - public function getDescription() : string - { - return $this->description; - } - - /** - * {@inheritdoc} - */ - public function getTags() : array - { - return $this->tags; + return $this->methods; } - /** - * {@inheritdoc} - */ - public function getHolder() : Reflector + public function allowsMethod(string $method): bool { - $handler = $this->requestHandler; - if ($handler instanceof CallableRequestHandler) { - $callback = $handler->getCallback(); - if ($callback instanceof Closure) { - return new ReflectionFunction($callback); - } - - /** @var array{0: class-string|object, 1: string} $callback */ - - return new ReflectionMethod(...$callback); - } - - return new ReflectionClass($handler); + return $this->methods === [] || in_array($method, $this->methods, true); } /** - * {@inheritdoc} + * @inheritDoc */ - public function setName(string $name) : RouteInterface + public function getAttributes(): array { - $this->name = $name; - - return $this; + return $this->attributes; } - /** - * {@inheritdoc} - */ - public function setHost(?string $host) : RouteInterface + public function hasAttribute(string $name): bool { - $this->host = $host; - - return $this; + return isset($this->attributes[$name]); } - /** - * {@inheritdoc} - */ - public function setPath(string $path) : RouteInterface + public function getAttribute(string $name, mixed $default = null): mixed { - $this->path = $path; - - return $this; + return $this->attributes[$name] ?? $default; } /** - * {@inheritdoc} + * @inheritDoc */ - public function setMethods(string ...$methods) : RouteInterface + public function withAddedAttributes(array $attributes): static { - foreach ($methods as &$method) { - $method = strtoupper($method); + $clone = clone $this; + foreach ($attributes as $name => $value) { + $clone->attributes[$name] = $value; } - $this->methods = $methods; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setRequestHandler(RequestHandlerInterface $requestHandler) : RouteInterface - { - $this->requestHandler = $requestHandler; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setMiddlewares(MiddlewareInterface ...$middlewares) : RouteInterface - { - $this->middlewares = $middlewares; - - return $this; + return $clone; } /** - * {@inheritdoc} + * @inheritDoc */ - public function setAttributes(array $attributes) : RouteInterface + public function getMiddlewares(): array { - $this->attributes = $attributes; - - return $this; + return $this->middlewares; } /** - * {@inheritdoc} + * @inheritDoc */ - public function setSummary(string $summary) : RouteInterface + public function getConsumedMediaTypes(): array { - $this->summary = $summary; - - return $this; + return $this->consumes; } /** - * {@inheritdoc} + * @inheritDoc */ - public function setDescription(string $description) : RouteInterface + public function getProducedMediaTypes(): array { - $this->description = $description; - - return $this; + return $this->produces; } /** - * {@inheritdoc} + * @inheritDoc */ - public function setTags(string ...$tags) : RouteInterface + public function getTags(): array { - $this->tags = $tags; - - return $this; + return $this->tags; } - /** - * {@inheritdoc} - */ - public function addPrefix(string $prefix) : RouteInterface + public function getSummary(): string { - // https://github.com/sunrise-php/http-router/issues/26 - $prefix = rtrim($prefix, '/'); - - $this->path = $prefix . $this->path; - - return $this; + return $this->summary; } - /** - * {@inheritdoc} - */ - public function addSuffix(string $suffix) : RouteInterface + public function getDescription(): string { - $this->path .= $suffix; - - return $this; + return $this->description; } - /** - * {@inheritdoc} - */ - public function addMethod(string ...$methods) : RouteInterface + public function isDeprecated(): bool { - foreach ($methods as $method) { - $this->methods[] = strtoupper($method); - } - - return $this; + return $this->isDeprecated; } /** - * {@inheritdoc} + * @inheritDoc */ - public function addMiddleware(MiddlewareInterface ...$middlewares) : RouteInterface + public function isApiOperation(): bool { - foreach ($middlewares as $middleware) { - $this->middlewares[] = $middleware; - } - - return $this; + return $this->isApiOperation; } /** - * {@inheritdoc} + * @inheritDoc */ - public function withAddedAttributes(array $attributes) : RouteInterface + public function getApiOperationFields(): array|object|null { - $clone = clone $this; - - foreach ($attributes as $key => $value) { - $clone->attributes[$key] = $value; - } - - return $clone; + return $this->apiOperationFields; } /** - * {@inheritdoc} + * @inheritDoc */ - public function handle(ServerRequestInterface $request) : ResponseInterface + public function getPattern(): ?string { - $request = $request->withAttribute(self::ATTR_ROUTE, $this); - - /** @todo Must be removed from the 3.0.0 version */ - $request = $request->withAttribute(self::ATTR_NAME_FOR_ROUTE_NAME, $this->name); - - foreach ($this->attributes as $key => $value) { - $request = $request->withAttribute($key, $value); - } - - if (empty($this->middlewares)) { - return $this->requestHandler->handle($request); - } - - $handler = new QueueableRequestHandler($this->requestHandler); - $handler->add(...$this->middlewares); - - return $handler->handle($request); + return $this->pattern; } } diff --git a/src/RouteCollection.php b/src/RouteCollection.php deleted file mode 100644 index 8dcbf0ce..00000000 --- a/src/RouteCollection.php +++ /dev/null @@ -1,178 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Psr\Http\Server\MiddlewareInterface; - -/** - * Import functions - */ -use function array_merge; - -/** - * RouteCollection - * - * Use the {@see RouteCollectionFactory} factory to create this class. - */ -class RouteCollection implements RouteCollectionInterface -{ - - /** - * The collection routes - * - * @var RouteInterface[] - */ - private $routes; - - /** - * Constructor of the class - * - * @param RouteInterface ...$routes - */ - public function __construct(RouteInterface ...$routes) - { - $this->routes = $routes; - } - - /** - * {@inheritdoc} - */ - public function all() : array - { - return $this->routes; - } - - /** - * {@inheritdoc} - */ - public function get(string $name) : ?RouteInterface - { - foreach ($this->routes as $route) { - if ($name === $route->getName()) { - return $route; - } - } - - return null; - } - - /** - * {@inheritdoc} - */ - public function has(string $name) : bool - { - return $this->get($name) instanceof RouteInterface; - } - - /** - * {@inheritdoc} - */ - public function add(RouteInterface ...$routes) : RouteCollectionInterface - { - foreach ($routes as $route) { - $this->routes[] = $route; - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setHost(string $host) : RouteCollectionInterface - { - foreach ($this->routes as $route) { - $route->setHost($host); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addPrefix(string $prefix) : RouteCollectionInterface - { - foreach ($this->routes as $route) { - $route->addPrefix($prefix); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addSuffix(string $suffix) : RouteCollectionInterface - { - foreach ($this->routes as $route) { - $route->addSuffix($suffix); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addMethod(string ...$methods) : RouteCollectionInterface - { - foreach ($this->routes as $route) { - $route->addMethod(...$methods); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addMiddleware(MiddlewareInterface ...$middlewares) : RouteCollectionInterface - { - foreach ($this->routes as $route) { - $route->addMiddleware(...$middlewares); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function prependMiddleware(MiddlewareInterface ...$middlewares) : RouteCollectionInterface - { - foreach ($this->routes as $route) { - $route->setMiddlewares(...array_merge($middlewares, $route->getMiddlewares())); - } - - return $this; - } - - /** - * @deprecated 2.12.0 Use the addMiddleware method. - */ - public function appendMiddleware(MiddlewareInterface ...$middlewares) : RouteCollectionInterface - { - return $this->addMiddleware(...$middlewares); - } - - /** - * @deprecated 2.10.0 Use the prependMiddleware method. - */ - public function unshiftMiddleware(MiddlewareInterface ...$middlewares) : RouteCollectionInterface - { - return $this->prependMiddleware(...$middlewares); - } -} diff --git a/src/RouteCollectionFactory.php b/src/RouteCollectionFactory.php deleted file mode 100644 index eec33899..00000000 --- a/src/RouteCollectionFactory.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * RouteCollectionFactory - */ -class RouteCollectionFactory implements RouteCollectionFactoryInterface -{ - - /** - * {@inheritdoc} - */ - public function createCollection(RouteInterface ...$routes) : RouteCollectionInterface - { - return new RouteCollection(...$routes); - } -} diff --git a/src/RouteCollectionFactoryInterface.php b/src/RouteCollectionFactoryInterface.php deleted file mode 100644 index e69350c2..00000000 --- a/src/RouteCollectionFactoryInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * RouteCollectionFactoryInterface - */ -interface RouteCollectionFactoryInterface -{ - - /** - * Creates a route collection with the given route(s) - * - * @param RouteInterface ...$routes - * - * @return RouteCollectionInterface - */ - public function createCollection(RouteInterface ...$routes) : RouteCollectionInterface; -} diff --git a/src/RouteCollectionInterface.php b/src/RouteCollectionInterface.php deleted file mode 100644 index 6adc3211..00000000 --- a/src/RouteCollectionInterface.php +++ /dev/null @@ -1,128 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Psr\Http\Server\MiddlewareInterface; - -/** - * RouteCollectionInterface - */ -interface RouteCollectionInterface -{ - - /** - * Gets all routes from the collection - * - * @return RouteInterface[] - */ - public function all() : array; - - /** - * Gets a route by the given name - * - * @param string $name - * - * @return RouteInterface|null - * - * @since 2.10.0 - */ - public function get(string $name) : ?RouteInterface; - - /** - * Checks by the given name if a route exists in the collection - * - * @param string $name - * - * @return bool - * - * @since 2.10.0 - */ - public function has(string $name) : bool; - - /** - * Adds the given route(s) to the collection - * - * @param RouteInterface ...$routes - * - * @return RouteCollectionInterface - */ - public function add(RouteInterface ...$routes) : RouteCollectionInterface; - - /** - * Sets the given host to all routes in the collection - * - * @param string $host - * - * @return RouteCollectionInterface - * - * @since 2.9.0 - */ - public function setHost(string $host) : RouteCollectionInterface; - - /** - * Adds the given path prefix to all routes in the collection - * - * @param string $prefix - * - * @return RouteCollectionInterface - * - * @since 2.9.0 - */ - public function addPrefix(string $prefix) : RouteCollectionInterface; - - /** - * Adds the given path suffix to all routes in the collection - * - * @param string $suffix - * - * @return RouteCollectionInterface - * - * @since 2.9.0 - */ - public function addSuffix(string $suffix) : RouteCollectionInterface; - - /** - * Adds the given method(s) to all routes in the collection - * - * @param string ...$methods - * - * @return RouteCollectionInterface - * - * @since 2.9.0 - */ - public function addMethod(string ...$methods) : RouteCollectionInterface; - - /** - * Adds the given middleware(s) to all routes in the collection - * - * @param MiddlewareInterface ...$middlewares - * - * @return RouteCollectionInterface - * - * @since 2.9.0 - */ - public function addMiddleware(MiddlewareInterface ...$middlewares) : RouteCollectionInterface; - - /** - * Adds the given middleware(s) to the beginning of all routes in the collection - * - * @param MiddlewareInterface ...$middlewares - * - * @return RouteCollectionInterface - * - * @since 2.9.0 - */ - public function prependMiddleware(MiddlewareInterface ...$middlewares) : RouteCollectionInterface; -} diff --git a/src/RouteCollector.php b/src/RouteCollector.php deleted file mode 100644 index 7e8653fc..00000000 --- a/src/RouteCollector.php +++ /dev/null @@ -1,398 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Psr\Container\ContainerInterface; -use Sunrise\Http\Router\Exception\UnresolvableReferenceException; - -/** - * RouteCollector - */ -class RouteCollector -{ - - /** - * Route collection factory of the collector - * - * @var RouteCollectionFactoryInterface - */ - private $collectionFactory; - - /** - * Route factory of the collector - * - * @var RouteFactoryInterface - */ - private $routeFactory; - - /** - * Reference resolver of the collector - * - * @var ReferenceResolverInterface - */ - private $referenceResolver; - - /** - * Route collection of the collector - * - * @var RouteCollectionInterface - */ - private $collection; - - /** - * Constructor of the class - * - * @param RouteCollectionFactoryInterface|null $collectionFactory - * @param RouteFactoryInterface|null $routeFactory - * @param ReferenceResolverInterface|null $referenceResolver - */ - public function __construct( - ?RouteCollectionFactoryInterface $collectionFactory = null, - ?RouteFactoryInterface $routeFactory = null, - ?ReferenceResolverInterface $referenceResolver = null - ) { - $this->collectionFactory = $collectionFactory ?? new RouteCollectionFactory(); - $this->routeFactory = $routeFactory ?? new RouteFactory(); - $this->referenceResolver = $referenceResolver ?? new ReferenceResolver(); - - $this->collection = $this->collectionFactory->createCollection(); - } - - /** - * Gets the collector collection - * - * @return RouteCollectionInterface - */ - public function getCollection() : RouteCollectionInterface - { - return $this->collection; - } - - /** - * Gets the collector container - * - * @return ContainerInterface|null - * - * @since 2.9.0 - */ - public function getContainer() : ?ContainerInterface - { - return $this->referenceResolver->getContainer(); - } - - /** - * Sets the given container to the collector - * - * @param ContainerInterface|null $container - * - * @return void - * - * @since 2.9.0 - */ - public function setContainer(?ContainerInterface $container) : void - { - $this->referenceResolver->setContainer($container); - } - - /** - * Makes a new route from the given parameters - * - * @param string $name - * @param string $path - * @param string[] $methods - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function route( - string $name, - string $path, - array $methods, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - foreach ($middlewares as &$middleware) { - $middleware = $this->referenceResolver->toMiddleware($middleware); - } - - $route = $this->routeFactory->createRoute( - $name, - $path, - $methods, - $this->referenceResolver->toRequestHandler($requestHandler), - $middlewares, - $attributes - ); - - $this->collection->add($route); - - return $route; - } - - /** - * Makes a new route that will respond to HEAD requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function head( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_HEAD], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Makes a new route that will respond to GET requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function get( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_GET], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Makes a new route that will respond to POST requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function post( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_POST], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Makes a new route that will respond to PUT requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function put( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_PUT], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Makes a new route that will respond to PATCH requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function patch( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_PATCH], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Makes a new route that will respond to DELETE requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function delete( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_DELETE], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Makes a new route that will respond to PURGE requests - * - * @param string $name - * @param string $path - * @param mixed $requestHandler - * @param array $middlewares - * @param array $attributes - * - * @return RouteInterface - * - * @throws UnresolvableReferenceException - * If the given request handler or one of the given middlewares cannot be resolved. - */ - public function purge( - string $name, - string $path, - $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return $this->route( - $name, - $path, - [Router::METHOD_PURGE], - $requestHandler, - $middlewares, - $attributes - ); - } - - /** - * Route grouping logic - * - * @param callable $callback - * @param array $middlewares - * - * @return RouteCollectionInterface - * - * @throws UnresolvableReferenceException - * If one of the given middlewares cannot be resolved. - */ - public function group(callable $callback, array $middlewares = []) : RouteCollectionInterface - { - foreach ($middlewares as &$middleware) { - $middleware = $this->referenceResolver->toMiddleware($middleware); - } - - $collector = new self( - $this->collectionFactory, - $this->routeFactory, - $this->referenceResolver - ); - - $callback($collector); - - $collector->collection->prependMiddleware(...$middlewares); - - $this->collection->add(...$collector->collection->all()); - - return $collector->collection; - } -} diff --git a/src/RouteFactory.php b/src/RouteFactory.php deleted file mode 100644 index 343548a4..00000000 --- a/src/RouteFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Psr\Http\Server\RequestHandlerInterface; - -/** - * RouteFactory - */ -class RouteFactory implements RouteFactoryInterface -{ - - /** - * {@inheritdoc} - */ - public function createRoute( - string $name, - string $path, - array $methods, - RequestHandlerInterface $requestHandler, - array $middlewares = [], - array $attributes = [] - ) : RouteInterface { - return new Route( - $name, - $path, - $methods, - $requestHandler, - $middlewares, - $attributes - ); - } -} diff --git a/src/RouteFactoryInterface.php b/src/RouteFactoryInterface.php deleted file mode 100644 index 2fe69bf7..00000000 --- a/src/RouteFactoryInterface.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * RouteFactoryInterface - */ -interface RouteFactoryInterface -{ - - /** - * Creates a new route from the given parameters - * - * @param string $name - * @param string $path - * @param string[] $methods - * @param RequestHandlerInterface $requestHandler - * @param MiddlewareInterface[] $middlewares - * @param array $attributes - * - * @return RouteInterface - */ - public function createRoute( - string $name, - string $path, - array $methods, - RequestHandlerInterface $requestHandler, - array $middlewares, - array $attributes - ) : RouteInterface; -} diff --git a/src/RouteInterface.php b/src/RouteInterface.php index 31d739a3..97287d73 100644 --- a/src/RouteInterface.php +++ b/src/RouteInterface.php @@ -1,270 +1,135 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router; -/** - * Import classes - */ -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; -use ReflectionClass; -use ReflectionMethod; -use ReflectionFunction; -use Reflector; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; /** - * RouteInterface + * @since 1.0.0 */ -interface RouteInterface extends RequestHandlerInterface +interface RouteInterface { - /** - * Request attribute name for the route instance - * - * @var string - * - * @since 2.11.0 + * @since 2.0.0 */ - public const ATTR_ROUTE = '@route'; + public function getName(): string; - /** - * Gets the route name - * - * @return string - */ - public function getName() : string; + public function getPath(): string; /** - * Gets the route host - * - * @return string|null - * - * @since 2.6.0 - */ - public function getHost() : ?string; - - /** - * Gets the route path - * - * @return string - */ - public function getPath() : string; - - /** - * Gets the route methods - * - * @return string[] + * @since 2.0.0 */ - public function getMethods() : array; + public function getRequestHandler(): mixed; /** - * Gets the route request handler + * @return array * - * @return RequestHandlerInterface + * @since 3.0.0 */ - public function getRequestHandler() : RequestHandlerInterface; + public function getPatterns(): array; /** - * Gets the route middlewares - * - * @return MiddlewareInterface[] + * @return array */ - public function getMiddlewares() : array; + public function getMethods(): array; /** - * Gets the route attributes - * - * @return array + * @since 3.0.0 */ - public function getAttributes() : array; + public function allowsMethod(string $method): bool; /** - * Gets the route summary - * - * @return string - * - * @since 2.4.0 + * @return array */ - public function getSummary() : string; + public function getAttributes(): array; /** - * Gets the route description - * - * @return string - * - * @since 2.4.0 + * @since 3.0.0 */ - public function getDescription() : string; + public function hasAttribute(string $name): bool; /** - * Gets the route tags - * - * @return string[] - * - * @since 2.4.0 + * @since 3.0.0 */ - public function getTags() : array; + public function getAttribute(string $name, mixed $default = null): mixed; /** - * Gets the route holder - * - * @return ReflectionClass|ReflectionMethod|ReflectionFunction + * @param array $attributes * - * @since 2.14.0 + * @since 2.0.0 */ - public function getHolder() : Reflector; + public function withAddedAttributes(array $attributes): static; /** - * Sets the given name to the route - * - * @param string $name - * - * @return RouteInterface + * @since 2.0.0 */ - public function setName(string $name) : RouteInterface; + public function getMiddlewares(): array; /** - * Sets the given host to the route - * - * @param string|null $host + * @return array * - * @return RouteInterface - * - * @since 2.6.0 + * @since 3.0.0 */ - public function setHost(?string $host) : RouteInterface; + public function getConsumedMediaTypes(): array; /** - * Sets the given path to the route - * - * @param string $path + * @return array * - * @return RouteInterface + * @since 3.0.0 */ - public function setPath(string $path) : RouteInterface; + public function getProducedMediaTypes(): array; /** - * Sets the given method(s) to the route - * - * @param string ...$methods - * - * @return RouteInterface - */ - public function setMethods(string ...$methods) : RouteInterface; - - /** - * Sets the given request handler to the route - * - * @param RequestHandlerInterface $requestHandler - * - * @return RouteInterface - */ - public function setRequestHandler(RequestHandlerInterface $requestHandler) : RouteInterface; - - /** - * Sets the given middleware(s) to the route - * - * @param MiddlewareInterface ...$middlewares - * - * @return RouteInterface - */ - public function setMiddlewares(MiddlewareInterface ...$middlewares) : RouteInterface; - - /** - * Sets the given attributes to the route - * - * @param array $attributes - * - * @return RouteInterface - */ - public function setAttributes(array $attributes) : RouteInterface; - - /** - * Sets the given summary to the route - * - * @param string $summary - * - * @return RouteInterface + * @return array * * @since 2.4.0 */ - public function setSummary(string $summary) : RouteInterface; + public function getTags(): array; /** - * Sets the given description to the route - * - * @param string $description - * - * @return RouteInterface - * * @since 2.4.0 */ - public function setDescription(string $description) : RouteInterface; + public function getSummary(): string; /** - * Sets the given tag(s) to the route - * - * @param string ...$tags - * - * @return RouteInterface - * * @since 2.4.0 */ - public function setTags(string ...$tags) : RouteInterface; - - /** - * Adds the given prefix to the route path - * - * @param string $prefix - * - * @return RouteInterface - */ - public function addPrefix(string $prefix) : RouteInterface; + public function getDescription(): string; /** - * Adds the given suffix to the route path - * - * @param string $suffix - * - * @return RouteInterface + * @since 3.0.0 */ - public function addSuffix(string $suffix) : RouteInterface; + public function isDeprecated(): bool; /** - * Adds the given method(s) to the route - * - * @param string ...$methods - * - * @return RouteInterface + * @since 3.0.0 */ - public function addMethod(string ...$methods) : RouteInterface; + public function isApiOperation(): bool; /** - * Adds the given middleware(s) to the route + * @since 3.0.0 * - * @param MiddlewareInterface ...$middlewares + * @return array|object|null * - * @return RouteInterface + * @link https://github.com/OAI/OpenAPI-Specification/blob/bba1da7bfd9cb9a4f47ed6b91e824bb1cef12fdc/versions/3.1.1.md#fixed-fields-8 */ - public function addMiddleware(MiddlewareInterface ...$middlewares) : RouteInterface; + public function getApiOperationFields(): array|object|null; /** - * Returns the route clone with the given attributes - * - * This method MUST NOT change the state of the object. - * - * @param array $attributes + * @return non-empty-string|null * - * @return RouteInterface + * @since 3.0.0 */ - public function withAddedAttributes(array $attributes) : RouteInterface; + public function getPattern(): ?string; } diff --git a/src/Router.php b/src/Router.php index 6b973897..faf1b9ae 100644 --- a/src/Router.php +++ b/src/Router.php @@ -1,573 +1,336 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router; -/** - * Import classes - */ -use Fig\Http\Message\RequestMethodInterface; +use InvalidArgumentException; +use LogicException; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Sunrise\Http\Router\Event\RouteEvent; -use Sunrise\Http\Router\Exception\InvalidArgumentException; -use Sunrise\Http\Router\Exception\MethodNotAllowedException; -use Sunrise\Http\Router\Exception\PageNotFoundException; -use Sunrise\Http\Router\Exception\RouteNotFoundException; +use Sunrise\Http\Router\Dictionary\HeaderName; +use Sunrise\Http\Router\Dictionary\PlaceholderCode; +use Sunrise\Http\Router\Event\RoutePostRunEvent; +use Sunrise\Http\Router\Event\RoutePreRunEvent; +use Sunrise\Http\Router\Exception\HttpException; +use Sunrise\Http\Router\Exception\HttpExceptionFactory; +use Sunrise\Http\Router\Helper\RouteBuilder; +use Sunrise\Http\Router\Helper\RouteCompiler; +use Sunrise\Http\Router\Helper\RouteMatcher; use Sunrise\Http\Router\Loader\LoaderInterface; use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; use Sunrise\Http\Router\RequestHandler\QueueableRequestHandler; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use UnexpectedValueException; -/** - * Import functions - */ -use function Sunrise\Http\Router\path_build; -use function Sunrise\Http\Router\path_match; +use function array_flip; use function array_keys; -use function get_class; -use function spl_object_hash; +use function array_merge; +use function rawurldecode; use function sprintf; -/** - * Router - */ -class Router implements MiddlewareInterface, RequestHandlerInterface, RequestMethodInterface +final class Router implements RouterInterface { + private readonly ReferenceResolverInterface $referenceResolver; - /** - * Server Request attribute name for routing error instance - * - * @var string - */ - public const ATTR_NAME_FOR_ROUTING_ERROR = '@routing-error'; + private ?RequestHandlerInterface $requestHandler = null; - /** - * Global patterns - * - * @var array - * - * @since 2.9.0 - */ - public static $patterns = [ - '@slug' => '[0-9a-z-]+', - '@uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', - ]; + /** @var array */ + private array $routes = []; - /** - * The router's host table - * - * @var array - * The key is a host alias and values are hostnames. - */ - private $hosts = []; + /** @var array */ + private array $routePatterns = []; - /** - * The router's routes - * - * @var array - * The key is a route name. - */ - private $routes = []; - - /** - * The router's middlewares - * - * @var array - * The keys is an object hash. - */ - private $middlewares = []; + /** @var array */ + private array $routeRequestHandlers = []; - /** - * The router's matched route - * - * @var RouteInterface|null - */ - private $matchedRoute = null; + private bool $isLoaded = false; /** - * The router's event dispatcher - * - * @var EventDispatcherInterface|null - * - * @since 2.13.0 + * @since 3.0.0 */ - private $eventDispatcher = null; + public function __construct( + /** @var array */ + private readonly array $loaders = [], + /** @var array */ + private readonly array $middlewares = [], + /** @var array */ + private readonly array $routeMiddlewares = [], + ?ReferenceResolverInterface $referenceResolver = null, + private readonly ?EventDispatcherInterface $eventDispatcher = null, + ) { + $this->referenceResolver = $referenceResolver ?? ReferenceResolver::build(); + } /** - * Gets the router's host table - * - * @return array + * @inheritDoc * - * @since 2.6.0 + * @throws InvalidArgumentException */ - public function getHosts() : array + public function getRoutes(): array { - return $this->hosts; + $this->isLoaded or $this->load(); + + return $this->routes; } /** - * Resolves the given hostname - * - * @param string $hostname - * - * @return string|null + * @inheritDoc * - * @since 2.14.0 + * @throws InvalidArgumentException */ - public function resolveHostname(string $hostname) : ?string + public function getRoute(string $name): RouteInterface { - foreach ($this->hosts as $alias => $hostnames) { - foreach ($hostnames as $value) { - if ($hostname === $value) { - return $alias; - } - } + $routes = $this->getRoutes(); + + if (!isset($routes[$name])) { + throw new InvalidArgumentException(sprintf('The route %s does not exist.', $name)); } - return null; + return $routes[$name]; } /** - * Gets all routes + * @inheritDoc * - * @return RouteInterface[] + * @throws InvalidArgumentException */ - public function getRoutes() : array + public function hasRoute(string $name): bool { - $routes = []; - foreach ($this->routes as $route) { - $routes[] = $route; - } + $routes = $this->getRoutes(); - return $routes; + return isset($routes[$name]); } /** - * Gets routes by the given hostname + * @inheritDoc * - * @param string $hostname - * - * @return RouteInterface[] - * - * @since 2.14.0 + * @throws InvalidArgumentException */ - public function getRoutesByHostname(string $hostname) : array + public function addRoute(RouteInterface ...$routes): void { - // the hostname's alias. - $alias = $this->resolveHostname($hostname); - - $routes = []; - foreach ($this->routes as $route) { - $host = $route->getHost(); - if ($host === null || $host === $alias) { - $routes[] = $route; + foreach ($routes as $route) { + $name = $route->getName(); + + if (isset($this->routes[$name])) { + throw new InvalidArgumentException(sprintf('The route %s already exists.', $name)); } - } - return $routes; + $this->routes[$name] = $route; + } } /** - * Gets the router's middlewares + * @inheritDoc * - * @return MiddlewareInterface[] + * @throws HttpException + * @throws InvalidArgumentException + * @throws LogicException */ - public function getMiddlewares() : array + public function match(ServerRequestInterface $request): RouteInterface { - $middlewares = []; - foreach ($this->middlewares as $middleware) { - $middlewares[] = $middleware; + $routes = $this->getRoutes(); + + if ($routes === []) { + throw new LogicException('The router does not contain any routes.'); } - return $middlewares; - } + $requestPath = rawurldecode($request->getUri()->getPath()); + $requestMethod = $request->getMethod(); + $allowedMethods = []; - /** - * Gets the router's matched route - * - * @return RouteInterface|null - */ - public function getMatchedRoute() : ?RouteInterface - { - return $this->matchedRoute; - } + foreach ($routes as $route) { + $routePattern = $this->compileRoute($route); - /** - * Gets the router's event dispatcher - * - * @return EventDispatcherInterface|null - * - * @since 2.13.0 - */ - public function getEventDispatcher() : ?EventDispatcherInterface - { - return $this->eventDispatcher; - } + try { + if (!RouteMatcher::matchRoute($route->getName(), $routePattern, $requestPath, $matches)) { + continue; + } + } catch (UnexpectedValueException $e) { + throw HttpExceptionFactory::malformedUri(previous: $e); + } - /** - * Adds the given patterns to the router - * - * ```php - * $router->addPatterns([ - * '@digit' => '\d+', - * '@word' => '\w+', - * ]); - * ``` - * - * ```php - * $route->setPath('/{foo<@digit>}/{bar<@word>}'); - * ``` - * - * @param array $patterns - * - * @return void - * - * @since 2.11.0 - */ - public function addPatterns(array $patterns) : void - { - foreach ($patterns as $alias => $pattern) { - self::$patterns[$alias] = $pattern; + if (!$route->allowsMethod($requestMethod)) { + $allowedMethods += array_flip($route->getMethods()); + continue; + } + + return $route->withAddedAttributes($matches); } - } - /** - * Adds aliases for hostnames to the router's host table - * - * ```php - * $router->addHosts([ - * 'local' => ['127.0.0.1', 'localhost'], - * ]); - * ``` - * - * ```php - * // will be available at 127.0.0.1 - * $route->setHost('local'); - * ``` - * - * @param array $hosts - * - * @return void - * - * @since 2.11.0 - */ - public function addHosts(array $hosts) : void - { - foreach ($hosts as $alias => $hostnames) { - $this->addHost($alias, ...$hostnames); + if ($allowedMethods !== []) { + throw HttpExceptionFactory::methodNotAllowed() + ->addMessagePlaceholder(PlaceholderCode::REQUEST_METHOD, $requestMethod) + ->addHeaderField(HeaderName::ALLOW, ...array_keys($allowedMethods)); } + + throw HttpExceptionFactory::resourceNotFound() + ->addMessagePlaceholder(PlaceholderCode::REQUEST_URI, $requestPath); } /** - * Adds the given alias for the given hostname(s) to the router's host table - * - * @param string $alias - * @param string ...$hostnames - * - * @return void + * @inheritDoc * - * @since 2.6.0 + * @throws HttpException + * @throws InvalidArgumentException + * @throws LogicException */ - public function addHost(string $alias, string ...$hostnames) : void + public function handle(ServerRequestInterface $request): ResponseInterface { - $this->hosts[$alias] = $hostnames; + return $this->getRequestHandler()->handle($request); } /** - * Adds the given route(s) to the router - * - * @param RouteInterface ...$routes - * - * @return void + * @inheritDoc * * @throws InvalidArgumentException - * if one of the given routes already exists. */ - public function addRoute(RouteInterface ...$routes) : void + public function runRoute(RouteInterface|string $route, ServerRequestInterface $request): ResponseInterface { - foreach ($routes as $route) { - $name = $route->getName(); - if (isset($this->routes[$name])) { - throw new InvalidArgumentException(sprintf( - 'The route "%s" already exists.', - $name - )); - } + if (! $route instanceof RouteInterface) { + $route = $this->getRoute($route); + } - $this->routes[$name] = $route; + foreach ($route->getAttributes() as $name => $value) { + $request = $request->withAttribute($name, $value); } - } - /** - * Adds the given middleware(s) to the router - * - * @param MiddlewareInterface ...$middlewares - * - * @return void - * - * @throws InvalidArgumentException - * if one of the given middlewares already exists. - */ - public function addMiddleware(MiddlewareInterface ...$middlewares) : void - { - foreach ($middlewares as $middleware) { - $hash = spl_object_hash($middleware); - if (isset($this->middlewares[$hash])) { - throw new InvalidArgumentException(sprintf( - 'The middleware "%s" already exists.', - get_class($middleware) - )); - } + $request = $request->withAttribute(RouteInterface::class, $route); - $this->middlewares[$hash] = $middleware; + if ($this->eventDispatcher !== null) { + $event = new RoutePreRunEvent($route, $request); + $this->eventDispatcher->dispatch($event); + $request = $event->request; } - } - /** - * Sets the given event dispatcher to the router - * - * @param EventDispatcherInterface|null $eventDispatcher - * - * @return void - * - * @since 2.13.0 - */ - public function setEventDispatcher(?EventDispatcherInterface $eventDispatcher) : void - { - $this->eventDispatcher = $eventDispatcher; - } + $response = $this->getRouteRequestHandler($route)->handle($request); - /** - * Gets allowed methods - * - * @return string[] - */ - public function getAllowedMethods() : array - { - $methods = []; - foreach ($this->routes as $route) { - foreach ($route->getMethods() as $method) { - $methods[$method] = true; - } + if ($this->eventDispatcher !== null) { + $event = new RoutePostRunEvent($route, $request, $response); + $this->eventDispatcher->dispatch($event); + $response = $event->response; } - return array_keys($methods); + return $response; } /** - * Checks if a route exists by the given name + * @inheritDoc * - * @return bool + * @throws InvalidArgumentException */ - public function hasRoute(string $name) : bool + public function buildRoute(RouteInterface|string $route, array $values = [], bool $strictly = false): string { - return isset($this->routes[$name]); - } + if (! $route instanceof RouteInterface) { + $route = $this->getRoute($route); + } - /** - * Gets a route for the given name - * - * @param string $name - * - * @return RouteInterface - * - * @throws RouteNotFoundException - */ - public function getRoute(string $name) : RouteInterface - { - if (!isset($this->routes[$name])) { - throw new RouteNotFoundException(sprintf( - 'No route found for the name "%s".', - $name + $result = RouteBuilder::buildRoute($route->getPath(), $values + $route->getAttributes()); + + if ($strictly && !RouteMatcher::matchRoute($route->getName(), $this->compileRoute($route), $result)) { + throw new InvalidArgumentException(sprintf( + 'The route %s could not be built because one of the values does not match its pattern.', + $route->getName(), )); } - return $this->routes[$name]; + return $result; } /** - * Generates a URI for the given named route + * @inheritDoc * - * @param string $name - * @param array $attributes - * @param bool $strict - * - * @return string - * - * @throws RouteNotFoundException - * If the given named route wasn't found. - * - * @throws Exception\InvalidAttributeValueException - * It can be thrown in strict mode, if an attribute value is not valid. - * - * @throws Exception\MissingAttributeValueException - * If a required attribute value is not given. + * @throws InvalidArgumentException */ - public function generateUri(string $name, array $attributes = [], bool $strict = false) : string + public function compileRoute(RouteInterface|string $route): string { - $route = $this->getRoute($name); - - $attributes += $route->getAttributes(); + if (! $route instanceof RouteInterface) { + $route = $this->getRoute($route); + } - return path_build($route->getPath(), $attributes, $strict); + return $this->routePatterns[$route->getName()] ??= $route->getPattern() + ?? RouteCompiler::compileRoute($route->getPath(), $route->getPatterns()); } /** - * Looks for a route that matches the given request - * - * @param ServerRequestInterface $request + * @inheritDoc * - * @return RouteInterface - * - * @throws MethodNotAllowedException - * @throws PageNotFoundException + * @throws InvalidArgumentException */ - public function match(ServerRequestInterface $request) : RouteInterface + public function getRouteRequestHandler(RouteInterface|string $route): RequestHandlerInterface { - $currentHost = $request->getUri()->getHost(); - $currentPath = $request->getUri()->getPath(); - $currentMethod = $request->getMethod(); - $allowedMethods = []; - - $routes = $this->getRoutesByHostname($currentHost); - - foreach ($routes as $route) { - // https://github.com/sunrise-php/http-router/issues/50 - // https://tools.ietf.org/html/rfc7231#section-6.5.5 - if (!path_match($route->getPath(), $currentPath, $attributes)) { - continue; - } - - $routeMethods = []; - foreach ($route->getMethods() as $routeMethod) { - $routeMethods[$routeMethod] = true; - $allowedMethods[$routeMethod] = true; - } - - if (!isset($routeMethods[$currentMethod])) { - continue; - } - - return $route->withAddedAttributes($attributes); + if (! $route instanceof RouteInterface) { + $route = $this->getRoute($route); } - if (!empty($allowedMethods)) { - throw new MethodNotAllowedException('Method Not Allowed', [ - 'method' => $currentMethod, - 'allowed' => array_keys($allowedMethods), - ]); + $name = $route->getName(); + if (isset($this->routeRequestHandlers[$name])) { + return $this->routeRequestHandlers[$name]; } - throw new PageNotFoundException('Page Not Found'); - } - - /** - * Runs the router - * - * @param ServerRequestInterface $request - * - * @return ResponseInterface - * - * @since 2.8.0 - */ - public function run(ServerRequestInterface $request) : ResponseInterface - { - // lazy resolving of the given request... - $routing = new CallableRequestHandler(function (ServerRequestInterface $request) : ResponseInterface { - $route = $this->match($request); - $this->matchedRoute = $route; - - if (isset($this->eventDispatcher)) { - $event = new RouteEvent($route, $request); + $this->routeRequestHandlers[$name] = $this->referenceResolver + ->resolveRequestHandler($route->getRequestHandler()); - /** - * @psalm-suppress TooManyArguments - */ - $this->eventDispatcher->dispatch($event, RouteEvent::NAME); - - $request = $event->getRequest(); + $middlewares = array_merge($this->routeMiddlewares, $route->getMiddlewares()); + if ($middlewares !== []) { + $this->routeRequestHandlers[$name] = new QueueableRequestHandler($this->routeRequestHandlers[$name]); + foreach ($middlewares as $middleware) { + $this->routeRequestHandlers[$name]->enqueue( + $this->referenceResolver->resolveMiddleware($middleware) + ); } - - return $route->handle($request); - }); - - $middlewares = $this->getMiddlewares(); - if (empty($middlewares)) { - return $routing->handle($request); } - $handler = new QueueableRequestHandler($routing); - $handler->add(...$middlewares); - - return $handler->handle($request); + return $this->routeRequestHandlers[$name]; } /** - * {@inheritdoc} + * @throws InvalidArgumentException */ - public function handle(ServerRequestInterface $request) : ResponseInterface + private function getRequestHandler(): RequestHandlerInterface { - $route = $this->match($request); - $this->matchedRoute = $route; - - if (isset($this->eventDispatcher)) { - $event = new RouteEvent($route, $request); - - /** - * @psalm-suppress TooManyArguments - */ - $this->eventDispatcher->dispatch($event, RouteEvent::NAME); - - $request = $event->getRequest(); + if ($this->requestHandler !== null) { + return $this->requestHandler; } - $middlewares = $this->getMiddlewares(); - if (empty($middlewares)) { - return $route->handle($request); + $this->requestHandler = new CallableRequestHandler( + fn(ServerRequestInterface $request): ResponseInterface + => $this->runRoute($this->match($request), $request), + ); + + if ($this->middlewares !== []) { + $this->requestHandler = new QueueableRequestHandler($this->requestHandler); + foreach ($this->middlewares as $middleware) { + $this->requestHandler->enqueue( + $this->referenceResolver->resolveMiddleware($middleware) + ); + } } - $handler = new QueueableRequestHandler($route); - $handler->add(...$middlewares); - - return $handler->handle($request); + return $this->requestHandler; } /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface - { - try { - return $this->handle($request); - } catch (MethodNotAllowedException|PageNotFoundException $e) { - $request = $request->withAttribute(self::ATTR_NAME_FOR_ROUTING_ERROR, $e); - - return $handler->handle($request); - } - } - - /** - * Loads routes through the given loaders - * - * @param LoaderInterface ...$loaders + * @throws InvalidArgumentException * - * @return void + * @since 3.0.0 This method has become private. */ - public function load(LoaderInterface ...$loaders) : void + private function load(): void { - foreach ($loaders as $loader) { - $this->addRoute(...$loader->load()->all()); + foreach ($this->loaders as $loader) { + $this->addRoute(...$loader->load()); } + + $this->isLoaded = true; } } diff --git a/src/RouterBuilder.php b/src/RouterBuilder.php deleted file mode 100644 index d8f017b0..00000000 --- a/src/RouterBuilder.php +++ /dev/null @@ -1,266 +0,0 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric - * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE - * @link https://github.com/sunrise-php/http-router - */ - -namespace Sunrise\Http\Router; - -/** - * Import classes - */ -use Psr\Container\ContainerInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\SimpleCache\CacheInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -/** - * RouterBuilder - * - * @since 2.9.0 - */ -final class RouterBuilder -{ - - /** - * @var EventDispatcherInterface|null - * - * @since 2.14.0 - */ - private $eventDispatcher = null; - - /** - * @var ContainerInterface|null - */ - private $container = null; - - /** - * @var CacheInterface|null - */ - private $cache = null; - - /** - * @var string|null - */ - private $cacheKey = null; - - /** - * @var array|null - */ - private $patterns = null; - - /** - * @var array|null - */ - private $hosts = null; - - /** - * @var MiddlewareInterface[]|null - */ - private $middlewares = null; - - /** - * @var Loader\ConfigLoader|null - */ - private $configLoader = null; - - /** - * @var Loader\DescriptorLoader|null - */ - private $descriptorLoader = null; - - /** - * Sets the given event dispatcher to the builder - * - * @param EventDispatcherInterface|null $eventDispatcher - * - * @return self - * - * @since 2.14.0 - */ - public function setEventDispatcher(?EventDispatcherInterface $eventDispatcher) : self - { - $this->eventDispatcher = $eventDispatcher; - - return $this; - } - - /** - * Sets the given container to the builder - * - * @param ContainerInterface|null $container - * - * @return self - */ - public function setContainer(?ContainerInterface $container) : self - { - $this->container = $container; - - return $this; - } - - /** - * Sets the given cache to the builder - * - * @param CacheInterface|null $cache - * - * @return self - */ - public function setCache(?CacheInterface $cache) : self - { - $this->cache = $cache; - - return $this; - } - - /** - * Sets the given cache key to the builder - * - * @param string|null $cacheKey - * - * @return self - * - * @since 2.10.0 - */ - public function setCacheKey(?string $cacheKey) : self - { - $this->cacheKey = $cacheKey; - - return $this; - } - - /** - * Uses the config loader when building - * - * @param string[] $resources - * - * @return self - */ - public function useConfigLoader(array $resources) : self - { - $this->configLoader = new Loader\ConfigLoader(); - $this->configLoader->attachArray($resources); - - return $this; - } - - /** - * Uses the descriptor loader when building - * - * @param string[] $resources - * - * @return self - */ - public function useDescriptorLoader(array $resources) : self - { - $this->descriptorLoader = new Loader\DescriptorLoader(); - $this->descriptorLoader->attachArray($resources); - - return $this; - } - - /** - * Uses the metadata loader when building - * - * Alias to the useDescriptorLoader method. - * - * @param string[] $resources - * - * @return self - */ - public function useMetadataLoader(array $resources) : self - { - $this->useDescriptorLoader($resources); - - return $this; - } - - /** - * Sets the given patterns to the builder - * - * @param array|null $patterns - * - * @return self - * - * @since 2.11.0 - */ - public function setPatterns(?array $patterns) : self - { - $this->patterns = $patterns; - - return $this; - } - - /** - * Sets the given hosts to the builder - * - * @param array|null $hosts - * - * @return self - */ - public function setHosts(?array $hosts) : self - { - $this->hosts = $hosts; - - return $this; - } - - /** - * Sets the given middlewares to the builder - * - * @param MiddlewareInterface[]|null $middlewares - * - * @return self - */ - public function setMiddlewares(?array $middlewares) : self - { - $this->middlewares = $middlewares; - - return $this; - } - - /** - * Builds the router - * - * @return Router - */ - public function build() : Router - { - $router = new Router(); - - if (isset($this->eventDispatcher)) { - $router->setEventDispatcher($this->eventDispatcher); - } - - if (isset($this->configLoader)) { - $this->configLoader->setContainer($this->container); - $router->load($this->configLoader); - } - - if (isset($this->descriptorLoader)) { - $this->descriptorLoader->setContainer($this->container); - $this->descriptorLoader->setCache($this->cache); - $this->descriptorLoader->setCacheKey($this->cacheKey); - $router->load($this->descriptorLoader); - } - - if (!empty($this->patterns)) { - $router->addPatterns($this->patterns); - } - - if (!empty($this->hosts)) { - $router->addHosts($this->hosts); - } - - if (!empty($this->middlewares)) { - $router->addMiddleware(...$this->middlewares); - } - - return $router; - } -} diff --git a/src/RouterInterface.php b/src/RouterInterface.php new file mode 100644 index 00000000..9e1257ab --- /dev/null +++ b/src/RouterInterface.php @@ -0,0 +1,100 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use InvalidArgumentException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Sunrise\Http\Router\Exception\HttpException; + +/** + * @since 3.0.0 + */ +interface RouterInterface extends RequestHandlerInterface +{ + /** + * @return array + * + * @since 1.0.0 + */ + public function getRoutes(): array; + + /** + * @throws InvalidArgumentException + * + * @since 2.0.0 + */ + public function getRoute(string $name): RouteInterface; + + /** + * @since 2.16.0 + */ + public function hasRoute(string $name): bool; + + /** + * @since 1.0.0 + * + * @throws InvalidArgumentException + */ + public function addRoute(RouteInterface ...$routes): void; + + /** + * @throws HttpException + * + * @since 1.0.0 + */ + public function match(ServerRequestInterface $request): RouteInterface; + + /** + * @inheritDoc + * + * @throws HttpException + * + * @since 1.0.0 + */ + public function handle(ServerRequestInterface $request): ResponseInterface; + + /** + * @throws InvalidArgumentException + * + * @since 3.0.0 + */ + public function runRoute(RouteInterface|string $route, ServerRequestInterface $request): ResponseInterface; + + /** + * @param array $values + * + * @throws InvalidArgumentException + * + * @since 3.0.0 + */ + public function buildRoute(RouteInterface|string $route, array $values = []): string; + + /** + * @return non-empty-string + * + * @throws InvalidArgumentException + * + * @since 3.0.0 + */ + public function compileRoute(RouteInterface|string $route): string; + + /** + * @throws InvalidArgumentException + * + * @since 3.0.0 + */ + public function getRouteRequestHandler(RouteInterface|string $route): RequestHandlerInterface; +} diff --git a/src/ServerRequest.php b/src/ServerRequest.php new file mode 100644 index 00000000..dd5348ad --- /dev/null +++ b/src/ServerRequest.php @@ -0,0 +1,552 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router; + +use Generator; +use LogicException; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; +use Sunrise\Http\Router\Dictionary\HeaderName; +use Sunrise\Http\Router\Entity\Locale\Locale; +use Sunrise\Http\Router\Entity\Locale\LocaleComparator; +use Sunrise\Http\Router\Entity\Locale\LocaleInterface; +use Sunrise\Http\Router\Entity\MediaType\MediaType; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeComparator; +use Sunrise\Http\Router\Entity\MediaType\MediaTypeInterface; +use Sunrise\Http\Router\Helper\HeaderParser; + +use function extension_loaded; +use function preg_match; +use function reset; +use function usort; + +/** + * @since 3.0.0 + */ +final class ServerRequest implements ServerRequestInterface +{ + public function __construct( + private ServerRequestInterface $request, + ) { + } + + public static function create(ServerRequestInterface $request): self + { + return ($request instanceof self) ? $request : new self($request); + } + + /** + * @throws LogicException If the request doesn't contain a route. + */ + public function getRoute(): RouteInterface + { + $route = $this->request->getAttribute(RouteInterface::class); + + if (! $route instanceof RouteInterface) { + throw new LogicException( + 'At this level of the application, the request does not contain information about the requested route.' + ); + } + + return $route; + } + + public function getClientProducedMediaType(): ?MediaTypeInterface + { + $values = HeaderParser::parseHeader($this->request->getHeaderLine(HeaderName::CONTENT_TYPE)); + if ($values === []) { + return null; + } + + [$identifier] = reset($values); + if (preg_match('|^([^/*]+)/([^/*]+)$|', $identifier) === 1) { + return new MediaType($identifier); + } + + return null; + } + + /** + * @return Generator + * + * @throws LogicException + */ + public function getClientConsumedLocales(): Generator + { + if (!extension_loaded('intl')) { + throw new LogicException( + 'To get the locales consumed by the client, ' . + 'the Intl (https://www.php.net/intl) extension must be installed.' + ); + } + + $values = HeaderParser::parseHeader($this->request->getHeaderLine(HeaderName::ACCEPT_LANGUAGE)); + if ($values === []) { + return; + } + + // https://github.com/php/php-src/blob/d9549d2ee215cb04aa5d2e3195c608d581fb272c/ext/standard/array.c#L900-L903 + usort($values, static fn(array $a, array $b): int => ( + (float) ($b[1]['q'] ?? '1') <=> (float) ($a[1]['q'] ?? '1') + )); + + foreach ($values as [$identifier]) { + $subtags = \Locale::parseLocale($identifier); + if (isset($subtags['language'])) { + /** @var string $languageCode */ + $languageCode = $subtags['language']; + /** @var string|null $regionCode */ + $regionCode = $subtags['region'] ?? null; + + yield new Locale($languageCode, $regionCode); + } + } + } + + /** + * @return Generator + */ + public function getClientConsumedMediaTypes(): Generator + { + $values = HeaderParser::parseHeader($this->request->getHeaderLine(HeaderName::ACCEPT)); + if ($values === []) { + return; + } + + // https://github.com/php/php-src/blob/d9549d2ee215cb04aa5d2e3195c608d581fb272c/ext/standard/array.c#L900-L903 + usort($values, static fn(array $a, array $b): int => ( + (float) ($b[1]['q'] ?? '1') <=> (float) ($a[1]['q'] ?? '1') + )); + + foreach ($values as [$identifier]) { + if (preg_match('|^([^/]+)/([^/]+)$|', $identifier) === 1) { + yield new MediaType($identifier); + } + } + } + + /** + * @param T ...$serverProducedLocales + * + * @return T|null + * + * @template T of LocaleInterface + */ + public function getClientPreferredLocale(LocaleInterface ...$serverProducedLocales): ?LocaleInterface + { + if ($serverProducedLocales === []) { + return null; + } + + foreach ($this->getClientConsumedLocales() as $clientConsumedLanguage) { + foreach ($serverProducedLocales as $serverProducedLanguage) { + if (LocaleComparator::compare($clientConsumedLanguage, $serverProducedLanguage) === 0) { + return $serverProducedLanguage; + } + } + } + + return null; + } + + /** + * @param T ...$serverProducedMediaTypes + * + * @return T|null + * + * @template T of MediaTypeInterface + */ + public function getClientPreferredMediaType(MediaTypeInterface ...$serverProducedMediaTypes): ?MediaTypeInterface + { + if ($serverProducedMediaTypes === []) { + return null; + } + + foreach ($this->getClientConsumedMediaTypes() as $clientConsumedMediaType) { + foreach ($serverProducedMediaTypes as $serverProducedMediaType) { + if (MediaTypeComparator::compare($clientConsumedMediaType, $serverProducedMediaType) === 0) { + return $serverProducedMediaType; + } + } + } + + return null; + } + + public function clientProducesMediaType(MediaTypeInterface ...$serverConsumedMediaTypes): bool + { + // The server is ready to accept any media type. + if ($serverConsumedMediaTypes === []) { + return true; + } + + $clientProducedMediaType = $this->getClientProducedMediaType(); + if ($clientProducedMediaType === null) { + return false; + } + + foreach ($serverConsumedMediaTypes as $serverConsumedMediaType) { + if (MediaTypeComparator::compare($clientProducedMediaType, $serverConsumedMediaType) === 0) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function getProtocolVersion(): string + { + return $this->request->getProtocolVersion(); + } + + /** + * @inheritDoc + */ + public function withProtocolVersion($version): self + { + $clone = clone $this; + $clone->request = $this->request->withProtocolVersion($version); + + return $clone; + } + + /** + * @inheritDoc + */ + public function getHeaders(): array + { + return $this->request->getHeaders(); + } + + /** + * @inheritDoc + */ + public function hasHeader($name): bool + { + return $this->request->hasHeader($name); + } + + /** + * @inheritDoc + */ + public function getHeader($name): array + { + return $this->request->getHeader($name); + } + + /** + * @inheritDoc + */ + public function getHeaderLine($name): string + { + return $this->request->getHeaderLine($name); + } + + /** + * @inheritDoc + */ + public function withHeader($name, $value): self + { + $clone = clone $this; + $clone->request = $this->request->withHeader($name, $value); + + return $clone; + } + + /** + * @inheritDoc + */ + public function withAddedHeader($name, $value): self + { + $clone = clone $this; + $clone->request = $this->request->withAddedHeader($name, $value); + + return $clone; + } + + /** + * @inheritDoc + */ + public function withoutHeader($name): self + { + $clone = clone $this; + $clone->request = $this->request->withoutHeader($name); + + return $clone; + } + + /** + * @inheritDoc + */ + public function getBody(): StreamInterface + { + return $this->request->getBody(); + } + + /** + * @inheritDoc + */ + public function withBody(StreamInterface $body): self + { + $clone = clone $this; + $clone->request = $this->request->withBody($body); + + return $clone; + } + + /** + * @inheritDoc + */ + public function getMethod(): string + { + return $this->request->getMethod(); + } + + /** + * @inheritDoc + */ + public function withMethod($method): self + { + $clone = clone $this; + $clone->request = $this->request->withMethod($method); + + return $clone; + } + + /** + * @inheritDoc + */ + public function getUri(): UriInterface + { + return $this->request->getUri(); + } + + /** + * @inheritDoc + */ + public function withUri(UriInterface $uri, $preserveHost = false): self + { + $clone = clone $this; + $clone->request = $this->request->withUri($uri, $preserveHost); + + return $clone; + } + + /** + * @inheritDoc + */ + public function getRequestTarget(): string + { + return $this->request->getRequestTarget(); + } + + /** + * @inheritDoc + */ + public function withRequestTarget($requestTarget): self + { + $clone = clone $this; + $clone->request = $this->request->withRequestTarget($requestTarget); + + return $clone; + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getServerParams(): array + { + return $this->request->getServerParams(); + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getQueryParams(): array + { + return $this->request->getQueryParams(); + } + + public function hasQueryParam(string $name): bool + { + return isset($this->request->getQueryParams()[$name]); + } + + public function getQueryParam(string $name, mixed $default = null): mixed + { + return $this->request->getQueryParams()[$name] ?? $default; + } + + /** + * {@inheritDoc} + * + * @param array $query + * + * @return static + */ + public function withQueryParams(array $query): self + { + $clone = clone $this; + $clone->request = $this->request->withQueryParams($query); + + return $clone; + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getCookieParams(): array + { + return $this->request->getCookieParams(); + } + + public function hasCookieParam(string $name): bool + { + return isset($this->request->getCookieParams()[$name]); + } + + public function getCookieParam(string $name, mixed $default = null): mixed + { + return $this->request->getCookieParams()[$name] ?? $default; + } + + /** + * {@inheritDoc} + * + * @param array $cookies + * + * @return static + */ + public function withCookieParams(array $cookies): self + { + $clone = clone $this; + $clone->request = $this->request->withCookieParams($cookies); + + return $clone; + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getUploadedFiles(): array + { + return $this->request->getUploadedFiles(); + } + + /** + * {@inheritDoc} + * + * @param array $uploadedFiles + * + * @return static + */ + public function withUploadedFiles(array $uploadedFiles): self + { + $clone = clone $this; + $clone->request = $this->request->withUploadedFiles($uploadedFiles); + + return $clone; + } + + /** + * {@inheritDoc} + * + * @return array|object|null + */ + public function getParsedBody(): mixed + { + return $this->request->getParsedBody(); + } + + /** + * {@inheritDoc} + * + * @param array|object|null $data + * + * @return static + */ + public function withParsedBody($data): self + { + $clone = clone $this; + $clone->request = $this->request->withParsedBody($data); + + return $clone; + } + + /** + * {@inheritDoc} + * + * @return array + */ + public function getAttributes(): array + { + return $this->request->getAttributes(); + } + + /** + * {@inheritDoc} + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getAttribute($name, $default = null): mixed + { + return $this->request->getAttribute($name, $default); + } + + /** + * {@inheritDoc} + * + * @param string $name + * @param mixed $value + * + * @return static + */ + public function withAttribute($name, $value): self + { + $clone = clone $this; + $clone->request = $this->request->withAttribute($name, $value); + + return $clone; + } + + /** + * {@inheritDoc} + * + * @param string $name + * + * @return static + */ + public function withoutAttribute($name): self + { + $clone = clone $this; + $clone->request = $this->request->withoutAttribute($name); + + return $clone; + } +} diff --git a/src/Validation/Constraint/ArgumentConstraint.php b/src/Validation/Constraint/ArgumentConstraint.php new file mode 100644 index 00000000..11db47a6 --- /dev/null +++ b/src/Validation/Constraint/ArgumentConstraint.php @@ -0,0 +1,36 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Validation\Constraint; + +use ReflectionParameter; +use Symfony\Component\Validator\Constraint; + +/** + * @since 3.0.0 + * + * @psalm-suppress PropertyNotSetInConstructor {@see parent::$groups} + */ +final class ArgumentConstraint extends Constraint +{ + public function __construct( + private readonly ReflectionParameter $parameter, + ) { + parent::__construct(); + } + + public function getParameter(): ReflectionParameter + { + return $this->parameter; + } +} diff --git a/src/Validation/Constraint/ArgumentConstraintValidator.php b/src/Validation/Constraint/ArgumentConstraintValidator.php new file mode 100644 index 00000000..d62fe6d9 --- /dev/null +++ b/src/Validation/Constraint/ArgumentConstraintValidator.php @@ -0,0 +1,60 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Validation\Constraint; + +use ReflectionAttribute; +use Sunrise\Http\Router\Annotation\Constraint as ConstraintAnnotation; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @since 3.0.0 + */ +final class ArgumentConstraintValidator extends ConstraintValidator +{ + /** + * @inheritDoc + * + * @throws UnexpectedTypeException + */ + public function validate(mixed $value, Constraint $constraint): void + { + if (! $constraint instanceof ArgumentConstraint) { + throw new UnexpectedTypeException($constraint, ArgumentConstraint::class); + } + + $constraints = []; + + /** @var list> $constraintAnnotations */ + $constraintAnnotations = $constraint->getParameter()->getAttributes(ConstraintAnnotation::class); + foreach ($constraintAnnotations as $constraintAnnotation) { + $parameterConstraints = $constraintAnnotation->newInstance()->values; + foreach ($parameterConstraints as $parameterConstraint) { + if ($parameterConstraint instanceof Constraint) { + $constraints[] = $parameterConstraint; + } + } + } + + if ($constraints === []) { + return; + } + + $this->context + ->getValidator() + ->inContext($this->context) + ->validate($value, $constraints); + } +} diff --git a/src/Validation/ConstraintViolation.php b/src/Validation/ConstraintViolation.php new file mode 100644 index 00000000..21426b41 --- /dev/null +++ b/src/Validation/ConstraintViolation.php @@ -0,0 +1,64 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Validation; + +/** + * @since 3.0.0 + */ +final class ConstraintViolation implements ConstraintViolationInterface +{ + public function __construct( + private readonly string $message, + private readonly string $messageTemplate, + /** @var array */ + private readonly array $messagePlaceholders, + private readonly string $propertyPath, + private readonly ?string $code, + private readonly mixed $invalidValue, + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function getMessageTemplate(): string + { + return $this->messageTemplate; + } + + /** + * @inheritDoc + */ + public function getMessagePlaceholders(): array + { + return $this->messagePlaceholders; + } + + public function getPropertyPath(): string + { + return $this->propertyPath; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function getInvalidValue(): mixed + { + return $this->invalidValue; + } +} diff --git a/src/Validation/ConstraintViolation/HydratorConstraintViolationAdapter.php b/src/Validation/ConstraintViolation/HydratorConstraintViolationAdapter.php new file mode 100644 index 00000000..e28521eb --- /dev/null +++ b/src/Validation/ConstraintViolation/HydratorConstraintViolationAdapter.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Validation\ConstraintViolation; + +use Sunrise\Http\Router\Validation\ConstraintViolationInterface; +use Sunrise\Hydrator\Exception\InvalidValueException; + +/** + * @since 3.0.0 + */ +final class HydratorConstraintViolationAdapter implements ConstraintViolationInterface +{ + public function __construct( + private readonly InvalidValueException $hydratorConstraintViolation, + ) { + } + + public static function create(InvalidValueException $hydratorConstraintViolation): self + { + return new self($hydratorConstraintViolation); + } + + public function getMessage(): string + { + return $this->hydratorConstraintViolation->getMessage(); + } + + public function getMessageTemplate(): string + { + return $this->hydratorConstraintViolation->getMessageTemplate(); + } + + /** + * @inheritDoc + */ + public function getMessagePlaceholders(): array + { + return $this->hydratorConstraintViolation->getMessagePlaceholders(); + } + + public function getPropertyPath(): string + { + return $this->hydratorConstraintViolation->getPropertyPath(); + } + + public function getCode(): ?string + { + return $this->hydratorConstraintViolation->getErrorCode(); + } + + public function getInvalidValue(): mixed + { + return $this->hydratorConstraintViolation->getInvalidValue(); + } +} diff --git a/src/Validation/ConstraintViolation/ValidatorConstraintViolationAdapter.php b/src/Validation/ConstraintViolation/ValidatorConstraintViolationAdapter.php new file mode 100644 index 00000000..146479c6 --- /dev/null +++ b/src/Validation/ConstraintViolation/ValidatorConstraintViolationAdapter.php @@ -0,0 +1,74 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Validation\ConstraintViolation; + +use Sunrise\Http\Router\Validation\ConstraintViolationInterface as RouterConstraintViolationInterface; +use Symfony\Component\Validator\ConstraintViolationInterface as ValidatorConstraintViolationInterface; + +use function preg_replace; + +/** + * @since 3.0.0 + */ +final class ValidatorConstraintViolationAdapter implements RouterConstraintViolationInterface +{ + public function __construct( + private readonly ValidatorConstraintViolationInterface $validatorConstraintViolation, + ) { + } + + public static function create(ValidatorConstraintViolationInterface $validatorConstraintViolation): self + { + return new self($validatorConstraintViolation); + } + + public function getMessage(): string + { + return (string) $this->validatorConstraintViolation->getMessage(); + } + + public function getMessageTemplate(): string + { + return $this->validatorConstraintViolation->getMessageTemplate(); + } + + /** + * @inheritDoc + */ + public function getMessagePlaceholders(): array + { + /** @var array */ + return $this->validatorConstraintViolation->getParameters(); + } + + public function getPropertyPath(): string + { + return self::adaptPropertyPath($this->validatorConstraintViolation->getPropertyPath()); + } + + public function getCode(): ?string + { + return $this->validatorConstraintViolation->getCode(); + } + + public function getInvalidValue(): mixed + { + return $this->validatorConstraintViolation->getInvalidValue(); + } + + private static function adaptPropertyPath(string $propertyPath): string + { + return (string) preg_replace(['/\x5b([^\x5b\x5d]+)\x5d/', '/^\x2e/'], ['.$1'], $propertyPath); + } +} diff --git a/src/Validation/ConstraintViolationInterface.php b/src/Validation/ConstraintViolationInterface.php new file mode 100644 index 00000000..41b43856 --- /dev/null +++ b/src/Validation/ConstraintViolationInterface.php @@ -0,0 +1,35 @@ + + * @copyright Copyright (c) 2018, Anatoly Nekhay + * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router + */ + +declare(strict_types=1); + +namespace Sunrise\Http\Router\Validation; + +/** + * @since 3.0.0 + */ +interface ConstraintViolationInterface +{ + public function getMessage(): string; + + public function getMessageTemplate(): string; + + /** + * @return array + */ + public function getMessagePlaceholders(): array; + + public function getPropertyPath(): string; + + public function getCode(): ?string; + + public function getInvalidValue(): mixed; +} diff --git a/functions/emit.php b/src/functions/emit.php similarity index 51% rename from functions/emit.php rename to src/functions/emit.php index 39466230..672906b4 100644 --- a/functions/emit.php +++ b/src/functions/emit.php @@ -1,54 +1,40 @@ - - * @copyright Copyright (c) 2018, Anatoly Fenric + * @author Anatoly Nekhay + * @copyright Copyright (c) 2018, Anatoly Nekhay * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE * @link https://github.com/sunrise-php/http-router */ +declare(strict_types=1); + namespace Sunrise\Http\Router; -/** - * Import classes - */ use Psr\Http\Message\ResponseInterface; -/** - * Import functions - */ use function header; use function sprintf; /** - * Sends the given response - * - * Don't use the function in your production environment, it's only for tests! - * - * @param ResponseInterface $response - * - * @return void + * @since 2.0.0 */ -function emit(ResponseInterface $response) : void +function emit(ResponseInterface $response): void { - foreach ($response->getHeaders() as $name => $values) { - foreach ($values as $value) { - header(sprintf( - '%s: %s', - $name, - $value - ), false); - } - } - header(sprintf( 'HTTP/%s %d %s', $response->getProtocolVersion(), $response->getStatusCode(), - $response->getReasonPhrase() - ), true); + $response->getReasonPhrase(), + )); + + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header(sprintf('%s: %s', $name, $value), replace: false); + } + } echo $response->getBody(); } diff --git a/tests/Command/RouteListCommandTest.php b/tests/Command/RouteListCommandTest.php deleted file mode 100644 index deab9ebf..00000000 --- a/tests/Command/RouteListCommandTest.php +++ /dev/null @@ -1,104 +0,0 @@ -addRoute(...[ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]); - - $command = new RouteListCommand($router); - - $this->assertSame('router:route-list', $command->getName()); - - $commandTester = new CommandTester($command); - - $this->assertSame(0, $commandTester->execute([])); - } - - /** - * @return void - */ - public function testRunInheritedCommand() : void - { - $command = new class extends RouteListCommand - { - public function __construct() - { - parent::__construct(null); - } - - protected function getRouter() : Router - { - return new Router(); - } - }; - - $this->assertSame('router:route-list', $command->getName()); - - $commandTester = new CommandTester($command); - - $this->assertSame(0, $commandTester->execute([])); - } - - /** - * @return void - */ - public function testRunRenamedCommand() : void - { - $command = new class extends RouteListCommand - { - protected static $defaultName = 'foo'; - protected static $defaultDescription = 'bar'; - - public function __construct() - { - parent::__construct(new Router()); - } - }; - - $this->assertSame('foo', $command->getName()); - $this->assertSame('bar', $command->getDescription()); - - $commandTester = new CommandTester($command); - - $this->assertSame(0, $commandTester->execute([])); - } - - /** - * @return void - */ - public function testRunWithoutRouter() : void - { - $command = new RouteListCommand(); - $commandTester = new CommandTester($command); - - $this->expectException(RuntimeException::class); - - $commandTester->execute([]); - } -} diff --git a/tests/Command/RouterClearDescriptorsCacheCommandTest.php b/tests/Command/RouterClearDescriptorsCacheCommandTest.php new file mode 100644 index 00000000..782da2a0 --- /dev/null +++ b/tests/Command/RouterClearDescriptorsCacheCommandTest.php @@ -0,0 +1,33 @@ +mockedDescriptorLoader = $this->createMock(DescriptorLoaderInterface::class); + } + + public function testExecute(): void + { + $this->mockedDescriptorLoader + ->expects(self::once()) + ->method('clearCache'); + + $command = new RouterClearDescriptorsCacheCommand($this->mockedDescriptorLoader); + $commandTester = new CommandTester($command); + $this->assertSame(Command::SUCCESS, $commandTester->execute([])); + } +} diff --git a/tests/Command/RouterListRoutesCommandTest.php b/tests/Command/RouterListRoutesCommandTest.php new file mode 100644 index 00000000..0c47f270 --- /dev/null +++ b/tests/Command/RouterListRoutesCommandTest.php @@ -0,0 +1,58 @@ + */ + private array $mockedRoutes = []; + + protected function setUp(): void + { + $this->mockedRouter = $this->createMock(RouterInterface::class); + $this->mockedRoutes = []; + } + + public function testExecuteWithRoutes(): void + { + $this->mockRoute(); + + $this->mockedRouter + ->expects(self::once()) + ->method('getRoutes') + ->willReturn($this->mockedRoutes); + + $command = new RouterListRoutesCommand($this->mockedRouter); + $commandTester = new CommandTester($command); + $this->assertSame(Command::SUCCESS, $commandTester->execute([])); + } + + public function testExecuteWithoutRoutes(): void + { + $this->mockedRouter + ->expects(self::once()) + ->method('getRoutes') + ->willReturn([]); + + $command = new RouterListRoutesCommand($this->mockedRouter); + $commandTester = new CommandTester($command); + $this->assertSame(Command::SUCCESS, $commandTester->execute([])); + } + + private function mockRoute(): RouteInterface&MockObject + { + return $this->mockedRoutes[] = $this->createMock(RouteInterface::class); + } +} diff --git a/tests/Entity/Locale/LocaleComparatorTest.php b/tests/Entity/Locale/LocaleComparatorTest.php new file mode 100644 index 00000000..de6c7c1e --- /dev/null +++ b/tests/Entity/Locale/LocaleComparatorTest.php @@ -0,0 +1,57 @@ +mockLocale($aLanguageCode, $aRegionCode); + $b = $this->mockLocale($bLanguageCode, $bRegionCode); + + $actualResult = LocaleComparator::compare($a, $b); + + $this->assertSame($expectedResult, $actualResult); + } + + public static function compareDataProvider(): iterable + { + yield ['sr', null, 'sr', null, 0]; + yield ['sr', null, 'bs', null, 1]; + yield ['bs', null, 'sr', null, -1]; + + yield ['sr', null, 'SR', null, 1]; + yield ['SR', null, 'sr', null, -1]; + + yield ['*', null, '*', null, 0]; + yield ['sr', null, '*', null, 0]; + yield ['*', null, 'sr', null, 0]; + + yield ['sr', 'RS', 'sr', 'RS', 0]; + yield ['sr', 'RS', 'sr', 'ME', 1]; + yield ['sr', 'ME', 'sr', 'RS', -1]; + } + + private function mockLocale(string $languageCode, ?string $regionCode = null): LocaleInterface&MockObject + { + $language = $this->createMock(LocaleInterface::class); + $language->method('getLanguageCode')->willReturn($languageCode); + $language->method('getRegionCode')->willReturn($regionCode); + + return $language; + } +} diff --git a/tests/Entity/Locale/LocaleTest.php b/tests/Entity/Locale/LocaleTest.php new file mode 100644 index 00000000..b89c2f32 --- /dev/null +++ b/tests/Entity/Locale/LocaleTest.php @@ -0,0 +1,18 @@ +assertSame('sr', $locale->getLanguageCode()); + $this->assertSame('RS', $locale->getRegionCode()); + } +} diff --git a/tests/Entity/MediaType/MediaTypeComparatorTest.php b/tests/Entity/MediaType/MediaTypeComparatorTest.php new file mode 100644 index 00000000..8469b170 --- /dev/null +++ b/tests/Entity/MediaType/MediaTypeComparatorTest.php @@ -0,0 +1,135 @@ +mockMediaType($aIdentifier); + $b = $this->mockMediaType($bIdentifier); + + $actualResult = MediaTypeComparator::compare($a, $b); + + $this->assertSame($expectedResult, $actualResult); + } + + public static function compareDataProvider(): iterable + { + yield [ + 'application/json', + 'application/json', + 0, + ]; + + yield [ + 'application/json', + 'application/xml', + -1, + ]; + + yield [ + 'application/xml', + 'application/json', + 1, + ]; + + yield [ + 'application/json', + 'APPLICATION/JSON', + 0, + ]; + + yield [ + 'APPLICATION/JSON', + 'application/json', + 0, + ]; + + yield [ + '*/*', + '*/*', + 0, + ]; + + yield [ + 'application/json', + '*/*', + 0, + ]; + + yield [ + '*/*', + 'application/json', + 0, + ]; + + yield [ + '*/json', + 'application/*', + 0, + ]; + + yield [ + 'application/*', + '*/json', + 0, + ]; + + yield [ + 'image/*', + 'image/*', + 0, + ]; + + yield [ + '*/webp', + '*/webp', + 0, + ]; + + yield [ + 'image/*', + 'image/webp', + 0, + ]; + + yield [ + 'image/webp', + 'image/*', + 0, + ]; + + yield [ + 'image/*', + 'video/*', + -1, + ]; + + yield [ + '*/jpeg', + '*/png', + -1, + ]; + } + + private function mockMediaType(string $identifier): MediaTypeInterface&MockObject + { + $mediaType = $this->createMock(MediaTypeInterface::class); + $mediaType->method('getIdentifier')->willReturn($identifier); + + return $mediaType; + } +} diff --git a/tests/Entity/MediaType/MediaTypeTest.php b/tests/Entity/MediaType/MediaTypeTest.php new file mode 100644 index 00000000..8d772127 --- /dev/null +++ b/tests/Entity/MediaType/MediaTypeTest.php @@ -0,0 +1,17 @@ +assertSame('application/json', $mediaType->getIdentifier()); + } +} diff --git a/tests/Event/RoutePostRunEventTest.php b/tests/Event/RoutePostRunEventTest.php new file mode 100644 index 00000000..d2ecb2aa --- /dev/null +++ b/tests/Event/RoutePostRunEventTest.php @@ -0,0 +1,39 @@ +mockedRoute = $this->createMock(RouteInterface::class); + $this->mockedServerRequest = $this->createMock(ServerRequestInterface::class); + $this->mockedResponse = $this->createMock(ResponseInterface::class); + } + + public function testConstructor(): void + { + $event = new RoutePostRunEvent( + $this->mockedRoute, + $this->mockedServerRequest, + $this->mockedResponse, + ); + + $this->assertSame($this->mockedRoute, $event->route); + $this->assertSame($this->mockedServerRequest, $event->request); + $this->assertSame($this->mockedResponse, $event->response); + } +} diff --git a/tests/Event/RoutePreRunEventTest.php b/tests/Event/RoutePreRunEventTest.php new file mode 100644 index 00000000..59cbebb9 --- /dev/null +++ b/tests/Event/RoutePreRunEventTest.php @@ -0,0 +1,34 @@ +mockedRoute = $this->createMock(RouteInterface::class); + $this->mockedServerRequest = $this->createMock(ServerRequestInterface::class); + } + + public function testConstructor(): void + { + $event = new RoutePreRunEvent( + $this->mockedRoute, + $this->mockedServerRequest, + ); + + $this->assertSame($this->mockedRoute, $event->route); + $this->assertSame($this->mockedServerRequest, $event->request); + } +} diff --git a/tests/Exception/BadRequestExceptionTest.php b/tests/Exception/BadRequestExceptionTest.php deleted file mode 100644 index 1eb50213..00000000 --- a/tests/Exception/BadRequestExceptionTest.php +++ /dev/null @@ -1,81 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } - - /** - * @return void - */ - public function testErrors() : void - { - $expected = [ - 'foo' => 'bar', - 'bar' => 'baz', - ]; - - $exception = new BadRequestException('blah', [ - 'errors' => $expected, - ]); - - $this->assertSame($expected, $exception->getErrors()); - } - - /** - * @return void - */ - public function testErrorsWithEmptyContext() : void - { - $exception = new BadRequestException('blah'); - - $this->assertSame([], $exception->getErrors()); - } - - /** - * @return void - */ - public function testViolations() : void - { - $expected = [ - 'foo' => 'bar', - 'bar' => 'baz', - ]; - - $exception = new BadRequestException('blah', [ - 'violations' => $expected, - ]); - - $this->assertSame($expected, $exception->getViolations()); - } - - /** - * @return void - */ - public function testViolationsWithEmptyContext() : void - { - $exception = new BadRequestException('blah'); - - $this->assertSame([], $exception->getViolations()); - } -} diff --git a/tests/Exception/ExceptionTest.php b/tests/Exception/ExceptionTest.php deleted file mode 100644 index 38804ab2..00000000 --- a/tests/Exception/ExceptionTest.php +++ /dev/null @@ -1,99 +0,0 @@ -assertInstanceOf(Throwable::class, $exception); - $this->assertInstanceOf(ExceptionInterface::class, $exception); - } - - /** - * @return void - */ - public function testConstructorWithoutParameters() : void - { - $exception = new Exception(); - - $this->assertSame('', $exception->getMessage()); - $this->assertSame([], $exception->getContext()); - $this->assertSame(0, $exception->getCode()); - $this->assertNull($exception->getPrevious()); - } - - /** - * @return void - */ - public function testMessage() : void - { - $message = 'blah'; - - $exception = new Exception($message); - - $this->assertSame($message, $exception->getMessage()); - } - - /** - * @return void - */ - public function testContext() : void - { - $context = [ - 'foo' => 'bar', - 'bar' => 'baz', - ]; - - $exception = new Exception('blah', $context); - - $this->assertSame($context, $exception->getContext()); - - $this->assertSame($context['foo'], $exception->fromContext('foo')); - $this->assertSame($context['bar'], $exception->fromContext('bar')); - - $this->assertNull($exception->fromContext('baz')); - $this->assertFalse($exception->fromContext('baz', false)); - } - - /** - * @return void - */ - public function testCode() : void - { - $code = 100; - - $exception = new Exception('blah', [], $code); - - $this->assertSame($code, $exception->getCode()); - } - - /** - * @return void - */ - public function testPrevious() : void - { - $previous = new Exception(); - - $exception = new Exception('blah', [], 0, $previous); - - $this->assertSame($previous, $exception->getPrevious()); - } -} diff --git a/tests/Exception/HttpExceptionTest.php b/tests/Exception/HttpExceptionTest.php new file mode 100644 index 00000000..c44fbe7c --- /dev/null +++ b/tests/Exception/HttpExceptionTest.php @@ -0,0 +1,221 @@ +createMock(Throwable::class); + $httpException = new HttpException('foo', 400, $previous); + $this->assertSame('foo', $httpException->getMessage()); + $this->assertSame(400, $httpException->getCode()); + $this->assertSame($previous, $httpException->getPrevious()); + $this->assertSame('foo', $httpException->getMessageTemplate()); + $this->assertSame([], $httpException->getMessagePlaceholders()); + $this->assertSame([], $httpException->getHeaderFields()); + $this->assertSame([], $httpException->getConstraintViolations()); + } + + public function testConstructorWithoutOptionalParameters(): void + { + $httpException = new HttpException('foo', 400); + $this->assertSame('foo', $httpException->getMessage()); + $this->assertSame(400, $httpException->getCode()); + $this->assertNull($httpException->getPrevious()); + $this->assertSame('foo', $httpException->getMessageTemplate()); + $this->assertSame([], $httpException->getMessagePlaceholders()); + $this->assertSame([], $httpException->getHeaderFields()); + $this->assertSame([], $httpException->getConstraintViolations()); + } + + public function testAddMessagePlaceholder(): void + { + } + + public function testAddHeaderFieldWithString(): void + { + $httpException = new HttpException('foo', 400); + + $httpException->addHeaderField('x-foo', 'bar', 'baz'); + $httpException->addHeaderField('x-foo', 'qux'); + $httpException->addHeaderField('x-foo'); + + $this->assertSame([ + ['x-foo', 'bar, baz'], + ['x-foo', 'qux'], + ['x-foo', ''], + ], $httpException->getHeaderFields()); + } + + public function testAddHeaderFieldWithStringableObject(): void + { + $httpException = new HttpException('foo', 400); + $httpException->addHeaderField('x-foo', $this->mockStringableObject('bar')); + $this->assertSame([['x-foo', 'bar']], $httpException->getHeaderFields()); + } + + public function testAddConstraintViolation(): void + { + $httpException = new HttpException('foo', 400); + $constraintViolations = []; + $constraintViolations[] = $this->createMock(ConstraintViolationInterface::class); + $constraintViolations[] = $this->createMock(ConstraintViolationInterface::class); + $httpException->addConstraintViolation(...$constraintViolations); + $additionalConstraintViolation = $this->createMock(ConstraintViolationInterface::class); + $httpException->addConstraintViolation($additionalConstraintViolation); + $expectedConstraintViolations = [...$constraintViolations, $additionalConstraintViolation]; + $this->assertSame($expectedConstraintViolations, $httpException->getConstraintViolations()); + } + + public function testAddHydratorConstraintViolations(): void + { + /** + * @var array{ + * 0: \Sunrise\Hydrator\Exception\InvalidValueException, + * 1: \Sunrise\Hydrator\Exception\InvalidValueException + * } $hydratorConstraintViolations + */ + $hydratorConstraintViolations = [ + $this->createHydratorConstraintViolation( + message: 'foo {bar}', + messageTemplate: 'foo {bar}', + messagePlaceholders: ['{bar}' => 'bar'], + propertyPath: ['foo', 'bar'], + errorCode: '00000000-0000-0000-0000-000000000000', + invalidValue: ['bar' => null], + ), + $this->createHydratorConstraintViolation( + message: 'bar {baz}', + messageTemplate: 'bar {baz}', + messagePlaceholders: ['{baz}' => 'baz'], + propertyPath: ['bar', 'baz'], + errorCode: '00000000-0000-0000-0000-000000000001', + invalidValue: ['baz' => null], + ), + ]; + + $additionalHydratorConstraintViolation = $this->createHydratorConstraintViolation( + message: 'baz {qux}', + messageTemplate: 'baz {qux}', + messagePlaceholders: ['{qux}' => 'qux'], + propertyPath: ['baz', 'qux'], + errorCode: '00000000-0000-0000-0000-000000000002', + invalidValue: ['qux' => null], + ); + + $httpException = new HttpException('foo', 400); + $httpException->addHydratorConstraintViolation(...$hydratorConstraintViolations); + $httpException->addHydratorConstraintViolation($additionalHydratorConstraintViolation); + $constraintViolations = $httpException->getConstraintViolations(); + $this->assertCount(3, $constraintViolations); + + $this->assertArrayHasKey(0, $constraintViolations); + $this->assertSame($hydratorConstraintViolations[0]->getMessage(), $constraintViolations[0]->getMessage()); + $this->assertSame($hydratorConstraintViolations[0]->getMessageTemplate(), $constraintViolations[0]->getMessageTemplate()); + $this->assertSame($hydratorConstraintViolations[0]->getMessagePlaceholders(), $constraintViolations[0]->getMessagePlaceholders()); + $this->assertSame($hydratorConstraintViolations[0]->getPropertyPath(), $constraintViolations[0]->getPropertyPath()); + $this->assertSame($hydratorConstraintViolations[0]->getErrorCode(), $constraintViolations[0]->getCode()); + $this->assertSame($hydratorConstraintViolations[0]->getInvalidValue(), $constraintViolations[0]->getInvalidValue()); + + $this->assertArrayHasKey(1, $constraintViolations); + $this->assertSame($hydratorConstraintViolations[1]->getMessage(), $constraintViolations[1]->getMessage()); + $this->assertSame($hydratorConstraintViolations[1]->getMessageTemplate(), $constraintViolations[1]->getMessageTemplate()); + $this->assertSame($hydratorConstraintViolations[1]->getMessagePlaceholders(), $constraintViolations[1]->getMessagePlaceholders()); + $this->assertSame($hydratorConstraintViolations[1]->getPropertyPath(), $constraintViolations[1]->getPropertyPath()); + $this->assertSame($hydratorConstraintViolations[1]->getErrorCode(), $constraintViolations[1]->getCode()); + $this->assertSame($hydratorConstraintViolations[1]->getInvalidValue(), $constraintViolations[1]->getInvalidValue()); + + $this->assertArrayHasKey(2, $constraintViolations); + $this->assertSame($additionalHydratorConstraintViolation->getMessage(), $constraintViolations[2]->getMessage()); + $this->assertSame($additionalHydratorConstraintViolation->getMessageTemplate(), $constraintViolations[2]->getMessageTemplate()); + $this->assertSame($additionalHydratorConstraintViolation->getMessagePlaceholders(), $constraintViolations[2]->getMessagePlaceholders()); + $this->assertSame($additionalHydratorConstraintViolation->getPropertyPath(), $constraintViolations[2]->getPropertyPath()); + $this->assertSame($additionalHydratorConstraintViolation->getErrorCode(), $constraintViolations[2]->getCode()); + $this->assertSame($additionalHydratorConstraintViolation->getInvalidValue(), $constraintViolations[2]->getInvalidValue()); + } + + public function testAddValidatorConstraintViolations(): void + { + /** + * @var array{ + * 0: \Symfony\Component\Validator\ConstraintViolationInterface&MockObject, + * 1: \Symfony\Component\Validator\ConstraintViolationInterface&MockObject + * } $validatorConstraintViolations + */ + $validatorConstraintViolations = [ + $this->mockValidatorConstraintViolation( + message: 'foo {bar}', + messageTemplate: 'foo {bar}', + messagePlaceholders: ['{bar}' => 'bar'], + propertyPath: 'foo.bar', + errorCode: '00000000-0000-0000-0000-000000000000', + invalidValue: ['bar' => null], + ), + $this->mockValidatorConstraintViolation( + message: 'bar {baz}', + messageTemplate: 'bar {baz}', + messagePlaceholders: ['{baz}' => 'baz'], + propertyPath: 'bar.baz', + errorCode: '00000000-0000-0000-0000-000000000001', + invalidValue: ['baz' => null], + ), + ]; + + $additionalValidatorConstraintViolation = $this->mockValidatorConstraintViolation( + message: 'baz {qux}', + messageTemplate: 'baz {qux}', + messagePlaceholders: ['{qux}' => 'qux'], + propertyPath: 'baz.qux', + errorCode: '00000000-0000-0000-0000-000000000002', + invalidValue: ['qux' => null], + ); + + $httpException = new HttpException('foo', 400); + $httpException->addValidatorConstraintViolation(...$validatorConstraintViolations); + $httpException->addValidatorConstraintViolation($additionalValidatorConstraintViolation); + $constraintViolations = $httpException->getConstraintViolations(); + $this->assertCount(3, $constraintViolations); + + $this->assertArrayHasKey(0, $constraintViolations); + $this->assertSame($validatorConstraintViolations[0]->getMessage(), $constraintViolations[0]->getMessage()); + $this->assertSame($validatorConstraintViolations[0]->getMessageTemplate(), $constraintViolations[0]->getMessageTemplate()); + $this->assertSame($validatorConstraintViolations[0]->getParameters(), $constraintViolations[0]->getMessagePlaceholders()); + $this->assertSame($validatorConstraintViolations[0]->getPropertyPath(), $constraintViolations[0]->getPropertyPath()); + $this->assertSame($validatorConstraintViolations[0]->getCode(), $constraintViolations[0]->getCode()); + $this->assertSame($validatorConstraintViolations[0]->getInvalidValue(), $constraintViolations[0]->getInvalidValue()); + + $this->assertArrayHasKey(1, $constraintViolations); + $this->assertSame($validatorConstraintViolations[1]->getMessage(), $constraintViolations[1]->getMessage()); + $this->assertSame($validatorConstraintViolations[1]->getMessageTemplate(), $constraintViolations[1]->getMessageTemplate()); + $this->assertSame($validatorConstraintViolations[1]->getParameters(), $constraintViolations[1]->getMessagePlaceholders()); + $this->assertSame($validatorConstraintViolations[1]->getPropertyPath(), $constraintViolations[1]->getPropertyPath()); + $this->assertSame($validatorConstraintViolations[1]->getCode(), $constraintViolations[1]->getCode()); + $this->assertSame($validatorConstraintViolations[1]->getInvalidValue(), $constraintViolations[1]->getInvalidValue()); + + $this->assertArrayHasKey(2, $constraintViolations); + $this->assertSame($additionalValidatorConstraintViolation->getMessage(), $constraintViolations[2]->getMessage()); + $this->assertSame($additionalValidatorConstraintViolation->getMessageTemplate(), $constraintViolations[2]->getMessageTemplate()); + $this->assertSame($additionalValidatorConstraintViolation->getParameters(), $constraintViolations[2]->getMessagePlaceholders()); + $this->assertSame($additionalValidatorConstraintViolation->getPropertyPath(), $constraintViolations[2]->getPropertyPath()); + $this->assertSame($additionalValidatorConstraintViolation->getCode(), $constraintViolations[2]->getCode()); + $this->assertSame($additionalValidatorConstraintViolation->getInvalidValue(), $constraintViolations[2]->getInvalidValue()); + } + + private function mockStringableObject(string $string): Stringable&MockObject + { + $stringableObjectMock = $this->createMock(Stringable::class); + $stringableObjectMock->method('__toString')->willReturn($string); + + return $stringableObjectMock; + } +} diff --git a/tests/Exception/InvalidArgumentExceptionTest.php b/tests/Exception/InvalidArgumentExceptionTest.php deleted file mode 100644 index 900ff25c..00000000 --- a/tests/Exception/InvalidArgumentExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/InvalidAttributeValueExceptionTest.php b/tests/Exception/InvalidAttributeValueExceptionTest.php deleted file mode 100644 index 93501e05..00000000 --- a/tests/Exception/InvalidAttributeValueExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/InvalidLoaderResourceExceptionTest.php b/tests/Exception/InvalidLoaderResourceExceptionTest.php deleted file mode 100644 index 098d36dc..00000000 --- a/tests/Exception/InvalidLoaderResourceExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/InvalidPathExceptionTest.php b/tests/Exception/InvalidPathExceptionTest.php deleted file mode 100644 index 4eb999ed..00000000 --- a/tests/Exception/InvalidPathExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/MethodNotAllowedExceptionTest.php b/tests/Exception/MethodNotAllowedExceptionTest.php deleted file mode 100644 index dcc651e3..00000000 --- a/tests/Exception/MethodNotAllowedExceptionTest.php +++ /dev/null @@ -1,106 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } - - /** - * @return void - */ - public function testMethod() : void - { - $expected = 'foo'; - - $exception = new MethodNotAllowedException('blah', [ - 'method' => $expected, - ]); - - $this->assertSame($expected, $exception->getMethod()); - } - - /** - * @return void - */ - public function testMethodWithEmptyContext() : void - { - $exception = new MethodNotAllowedException('blah'); - - $this->assertSame('', $exception->getMethod()); - } - - /** - * @return void - */ - public function testAllowedMethods() : void - { - $expected = ['foo', 'bar']; - - $exception = new MethodNotAllowedException('blah', [ - 'allowed' => $expected, - ]); - - $this->assertSame($expected, $exception->getAllowedMethods()); - } - - /** - * @return void - */ - public function testAllowedMethodsWithEmptyContext() : void - { - $exception = new MethodNotAllowedException('blah'); - - $this->assertSame([], $exception->getAllowedMethods()); - } - - /** - * @return void - */ - public function testGluingAllowedMethods() : void - { - $methods = ['foo', 'bar']; - - $expected = implode(',', $methods); - - $exception = new MethodNotAllowedException('blah', [ - 'allowed' => $methods, - ]); - - $this->assertSame($expected, $exception->getJoinedAllowedMethods()); - } - - /** - * @return void - */ - public function testGluingAllowedMethodsWithEmptyContext() : void - { - $exception = new MethodNotAllowedException('blah'); - - $this->assertSame('', $exception->getJoinedAllowedMethods()); - } -} diff --git a/tests/Exception/MissingAttributeValueExceptionTest.php b/tests/Exception/MissingAttributeValueExceptionTest.php deleted file mode 100644 index 57d1b7df..00000000 --- a/tests/Exception/MissingAttributeValueExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/PageNotFoundExceptionTest.php b/tests/Exception/PageNotFoundExceptionTest.php deleted file mode 100644 index c824a541..00000000 --- a/tests/Exception/PageNotFoundExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(RouteNotFoundException::class, $exception); - } -} diff --git a/tests/Exception/RouteNotFoundExceptionTest.php b/tests/Exception/RouteNotFoundExceptionTest.php deleted file mode 100644 index 04164112..00000000 --- a/tests/Exception/RouteNotFoundExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/UnresolvableReferenceExceptionTest.php b/tests/Exception/UnresolvableReferenceExceptionTest.php deleted file mode 100644 index dd414b4d..00000000 --- a/tests/Exception/UnresolvableReferenceExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } -} diff --git a/tests/Exception/UnsupportedMediaTypeExceptionTest.php b/tests/Exception/UnsupportedMediaTypeExceptionTest.php deleted file mode 100644 index c81ec6ef..00000000 --- a/tests/Exception/UnsupportedMediaTypeExceptionTest.php +++ /dev/null @@ -1,109 +0,0 @@ -assertInstanceOf(Exception::class, $exception); - } - - /** - * @return void - */ - public function testType() : void - { - $expected = 'application/octet-stream'; - - $exception = new UnsupportedMediaTypeException('blah', [ - 'type' => $expected, - ]); - - $this->assertSame($expected, $exception->getType()); - } - - /** - * @return void - */ - public function testTypeWithEmptyContext() : void - { - $exception = new UnsupportedMediaTypeException('blah'); - - $this->assertSame('', $exception->getType()); - } - - /** - * @return void - */ - public function testSupportedTypes() : void - { - $expected = [ - 'application/json', - 'application/x-www-form-urlencoded', - ]; - - $exception = new UnsupportedMediaTypeException('blah', [ - 'supported' => $expected, - ]); - - $this->assertSame($expected, $exception->getSupportedTypes()); - } - - /** - * @return void - */ - public function testSupportedTypesWithEmptyContext() : void - { - $exception = new UnsupportedMediaTypeException('blah'); - - $this->assertSame([], $exception->getSupportedTypes()); - } - - /** - * @return void - */ - public function testGluingSupportedTypes() : void - { - $types = ['foo', 'bar']; - - $expected = implode(',', $types); - - $exception = new UnsupportedMediaTypeException('blah', [ - 'supported' => $types, - ]); - - $this->assertSame($expected, $exception->getJoinedSupportedTypes()); - } - - /** - * @return void - */ - public function testGluingSupportedTypesWithEmptyContext() : void - { - $exception = new UnsupportedMediaTypeException('blah'); - - $this->assertSame('', $exception->getJoinedSupportedTypes()); - } -} diff --git a/tests/Fixtures/CacheAwareTrait.php b/tests/Fixtures/CacheAwareTrait.php deleted file mode 100644 index 1224f7ee..00000000 --- a/tests/Fixtures/CacheAwareTrait.php +++ /dev/null @@ -1,32 +0,0 @@ -createMock(CacheInterface::class); - $cache->storage = []; - - $cache->method('get')->will($this->returnCallback(function ($key) use ($cache) { - return $cache->storage[$key] ?? null; - })); - - $cache->method('has')->will($this->returnCallback(function ($key) use ($cache) { - return isset($cache->storage[$key]); - })); - - $cache->method('set')->will($this->returnCallback(function ($key, $value) use ($cache) { - $cache->storage[$key] = $value; - })); - - return $cache; - } -} diff --git a/tests/Fixtures/ContainerAwareTrait.php b/tests/Fixtures/ContainerAwareTrait.php deleted file mode 100644 index 31bc8498..00000000 --- a/tests/Fixtures/ContainerAwareTrait.php +++ /dev/null @@ -1,28 +0,0 @@ -createMock(ContainerInterface::class); - $container->storage = $definitions; - - $container->method('get')->will($this->returnCallback(function ($key) use ($container) { - return $container->storage[$key] ?? null; - })); - - $container->method('has')->will($this->returnCallback(function ($key) use ($container) { - return isset($container->storage[$key]); - })); - - return $container; - } -} diff --git a/tests/Fixtures/Controllers/AbstractController.php b/tests/Fixtures/Controllers/AbstractController.php deleted file mode 100644 index fb36ce4c..00000000 --- a/tests/Fixtures/Controllers/AbstractController.php +++ /dev/null @@ -1,57 +0,0 @@ -isRunned; - } - - /** - * Gets the request that was handled by the called method - * - * @return ServerRequestInterface|null - */ - public function getRequest() : ?ServerRequestInterface - { - return $this->request; - } - - /** - * {@inheritdoc} - */ - public function handle(ServerRequestInterface $request) : ResponseInterface - { - $this->isRunned = true; - $this->request = $request; - - return (new ResponseFactory)->createResponse(200); - } -} diff --git a/tests/Fixtures/Controllers/Annotated/AbstractAnnotatedController.php b/tests/Fixtures/Controllers/Annotated/AbstractAnnotatedController.php deleted file mode 100644 index 8a83239f..00000000 --- a/tests/Fixtures/Controllers/Annotated/AbstractAnnotatedController.php +++ /dev/null @@ -1,12 +0,0 @@ -handle($request); - } - - /** - * @Route("second-from-grouped-annotated-controller", path="/second") - * @Middleware("Sunrise\Http\Router\Tests\Fixtures\Middlewares\BlankMiddleware") - * @Middleware("Sunrise\Http\Router\Tests\Fixtures\Middlewares\BlankMiddleware") - * @Middleware("Sunrise\Http\Router\Tests\Fixtures\Middlewares\BlankMiddleware") - */ - public function second($request) - { - return $this->handle($request); - } - - /** - * @Route("third-from-grouped-annotated-controller", path="/third") - * @Middleware("Sunrise\Http\Router\Tests\Fixtures\Middlewares\BlankMiddleware") - * @Middleware("Sunrise\Http\Router\Tests\Fixtures\Middlewares\BlankMiddleware") - * @Middleware("Sunrise\Http\Router\Tests\Fixtures\Middlewares\BlankMiddleware") - */ - public function third($request) - { - return $this->handle($request); - } - - /** - * @Route("private-from-grouped-annotated-controller", path="/") - */ - private function privateAction() - { - } - - /** - * @Route("protected-from-grouped-annotated-controller", path="/") - */ - protected function protectedAction() - { - } - - /** - * @Route("static-from-grouped-annotated-controller", path="/") - */ - public static function staticAction() - { - } - - public function shouldBeIgnored() - { - } -} diff --git a/tests/Fixtures/Controllers/Annotated/Loadable/0/FirstLoadableAnnotatedController.php b/tests/Fixtures/Controllers/Annotated/Loadable/0/FirstLoadableAnnotatedController.php deleted file mode 100644 index 81972be7..00000000 --- a/tests/Fixtures/Controllers/Annotated/Loadable/0/FirstLoadableAnnotatedController.php +++ /dev/null @@ -1,16 +0,0 @@ -handle($request); - } - - #[Routing\Route('second-from-grouped-attributed-controller', path: '/second')] - #[Routing\Middleware(BlankMiddleware::class)] - #[Routing\Middleware(BlankMiddleware::class)] - #[Routing\Middleware(BlankMiddleware::class)] - public function second($request) - { - return $this->handle($request); - } - - #[Routing\Route('third-from-grouped-attributed-controller', path: '/third')] - #[Routing\Middleware(BlankMiddleware::class)] - #[Routing\Middleware(BlankMiddleware::class)] - #[Routing\Middleware(BlankMiddleware::class)] - public function third($request) - { - return $this->handle($request); - } -} diff --git a/tests/Fixtures/Controllers/Attributed/MaximallyAttributedController.php b/tests/Fixtures/Controllers/Attributed/MaximallyAttributedController.php deleted file mode 100644 index 27446c9f..00000000 --- a/tests/Fixtures/Controllers/Attributed/MaximallyAttributedController.php +++ /dev/null @@ -1,27 +0,0 @@ - 'bar', - ], - summary: 'Lorem ipsum', - description: 'Lorem ipsum dolor sit amet', - tags: ['foo', 'bar'], -)] -final class MaximallyAttributedController extends AbstractController -{ -} diff --git a/tests/Fixtures/Controllers/Attributed/MinimallyAttributedController.php b/tests/Fixtures/Controllers/Attributed/MinimallyAttributedController.php deleted file mode 100644 index ec8f7767..00000000 --- a/tests/Fixtures/Controllers/Attributed/MinimallyAttributedController.php +++ /dev/null @@ -1,15 +0,0 @@ -handle($request)->withStatus(305); - } -} diff --git a/tests/Fixtures/Middlewares/AbstractMiddleware.php b/tests/Fixtures/Middlewares/AbstractMiddleware.php deleted file mode 100644 index df27cc1d..00000000 --- a/tests/Fixtures/Middlewares/AbstractMiddleware.php +++ /dev/null @@ -1,79 +0,0 @@ -isBreakable = $isBreakable; - } - - /** - * Checks if the middleware was runned - * - * @return bool - */ - public function isRunned() : bool - { - return $this->isRunned; - } - - /** - * Gets the request that was handled by the called method - * - * @return ServerRequestInterface|null - */ - public function getRequest() : ?ServerRequestInterface - { - return $this->request; - } - - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface - { - $this->isRunned = true; - $this->request = $request; - - if ($this->isBreakable) { - return (new ResponseFactory)->createResponse(200); - } - - return $handler->handle($request); - } -} diff --git a/tests/Fixtures/Middlewares/BlankMiddleware.php b/tests/Fixtures/Middlewares/BlankMiddleware.php deleted file mode 100644 index 79b6e8e2..00000000 --- a/tests/Fixtures/Middlewares/BlankMiddleware.php +++ /dev/null @@ -1,24 +0,0 @@ -process($request, $handler)->withStatus(305); - } -} diff --git a/tests/Fixtures/Route.php b/tests/Fixtures/Route.php deleted file mode 100644 index 2fbd50f7..00000000 --- a/tests/Fixtures/Route.php +++ /dev/null @@ -1,34 +0,0 @@ -get('bar', '/bar', new BlankController()); diff --git a/tests/Fixtures/routes/foo.php b/tests/Fixtures/routes/foo.php deleted file mode 100644 index 1421ca93..00000000 --- a/tests/Fixtures/routes/foo.php +++ /dev/null @@ -1,7 +0,0 @@ -get('foo', '/foo', new BlankController()); diff --git a/tests/Fixtures/routes/resolvable/class-method-name.php b/tests/Fixtures/routes/resolvable/class-method-name.php deleted file mode 100644 index 78ebcf26..00000000 --- a/tests/Fixtures/routes/resolvable/class-method-name.php +++ /dev/null @@ -1,11 +0,0 @@ -get('resolvable-class-method-name-route', '/', $controller, [$middleware]); diff --git a/tests/Fixtures/routes/resolvable/class-name.php b/tests/Fixtures/routes/resolvable/class-name.php deleted file mode 100644 index 4abc3e73..00000000 --- a/tests/Fixtures/routes/resolvable/class-name.php +++ /dev/null @@ -1,11 +0,0 @@ -get('resolvable-class-name-route', '/', $controller, [$middleware]); diff --git a/tests/Fixtures/routes/resolvable/closure.php b/tests/Fixtures/routes/resolvable/closure.php deleted file mode 100644 index d625941c..00000000 --- a/tests/Fixtures/routes/resolvable/closure.php +++ /dev/null @@ -1,16 +0,0 @@ -handle($request); -}; - -$middleware = function ($request, $handler) { - return (new BlankMiddleware)->process($request, $handler); -}; - -$this->get('resolvable-closure-route', '/', $controller, [$middleware]); diff --git a/tests/Functions/FunctionPathBuildTest.php b/tests/Functions/FunctionPathBuildTest.php deleted file mode 100644 index 7dfd1615..00000000 --- a/tests/Functions/FunctionPathBuildTest.php +++ /dev/null @@ -1,87 +0,0 @@ -}/quuuuux)/{quuuuuux}'; - $expected = '/foo/quux/quuux/quuuux/quuuuux/quuuuuux'; - - $this->assertSame($expected, path_build($path, [ - 'quuuux' => 'quuuux', - 'quuuuuux' => 'quuuuuux', - ])); - } - - /** - * @return void - */ - public function testBuildPathWithoutRequiredAttribute() : void - { - $path = '/foo/{bar}/{baz}'; - - $this->expectException(MissingAttributeValueException::class); - $this->expectExceptionMessage( - '[' . $path . '] build error: no value given for the attribute "baz".' - ); - - try { - path_build($path, [ - 'bar' => 'bar', - ]); - } catch (MissingAttributeValueException $e) { - $this->assertSame(['path', 'match'], array_keys($e->getContext())); - $this->assertSame($path, $e->fromContext('path')); - - throw $e; - } - } - - /** - * @return void - */ - public function testBuildPathWithInvalidAttribute() : void - { - $path = '/foo/{bar}/{baz<[a-z]+>}'; - - $this->expectException(InvalidAttributeValueException::class); - $this->expectExceptionMessage( - '[' . $path . '] build error: the given value for the attribute "baz" does not match its pattern.' - ); - - try { - path_build($path, [ - 'bar' => 'bar', - 'baz' => '42', - ], true); - } catch (InvalidAttributeValueException $e) { - $this->assertSame(['path', 'value', 'match'], array_keys($e->getContext())); - $this->assertSame($path, $e->fromContext('path')); - $this->assertSame('42', $e->fromContext('value')); - - throw $e; - } - } -} diff --git a/tests/Functions/FunctionPathMatchTest.php b/tests/Functions/FunctionPathMatchTest.php deleted file mode 100644 index d5ec5763..00000000 --- a/tests/Functions/FunctionPathMatchTest.php +++ /dev/null @@ -1,42 +0,0 @@ -}(/)'; - - $this->assertTrue(path_match($path, '/foo/bar/baz/', $attributes)); - $this->assertSame([ - 'bar' => 'bar', - 'baz' => 'baz', - ], $attributes); - - $this->assertTrue(path_match($path, '/foo/baz', $attributes)); - $this->assertSame([ - 'baz' => 'baz', - ], $attributes); - - $this->assertFalse(path_match($path, '/foo/baz!', $attributes)); - $this->assertSame([], $attributes); - } -} diff --git a/tests/Functions/FunctionPathParseTest.php b/tests/Functions/FunctionPathParseTest.php deleted file mode 100644 index 581c505d..00000000 --- a/tests/Functions/FunctionPathParseTest.php +++ /dev/null @@ -1,313 +0,0 @@ -}/qux)/quux/{quuux<\d+>}/quuuux(/)'; - - $expected = [ - [ - 'raw' => '{foo}', - 'withParentheses' => null, - 'name' => 'foo', - 'pattern' => null, - 'isOptional' => false, - 'startPosition' => 1, - 'endPosition' => 5, - ], - [ - 'raw' => '{baz<\w+>}', - 'withParentheses' => '(/bar/{baz<\w+>}/qux)', - 'name' => 'baz', - 'pattern' => '\w+', - 'isOptional' => true, - 'startPosition' => 12, - 'endPosition' => 21, - ], - [ - 'raw' => '{quuux<\d+>}', - 'withParentheses' => null, - 'name' => 'quuux', - 'pattern' => '\d+', - 'isOptional' => false, - 'startPosition' => 33, - 'endPosition' => 44, - ], - ]; - - $this->assertSame($expected, path_parse($path)); - } - - /** - * @return void - */ - public function testNamedPatterns() : void - { - $path = '/{foo<@slug>}/{bar<@uuid>}'; - - $expected = [ - [ - 'raw' => '{foo<@slug>}', - 'withParentheses' => null, - 'name' => 'foo', - 'pattern' => Router::$patterns['@slug'], - 'isOptional' => false, - 'startPosition' => 1, - 'endPosition' => 12, - ], - [ - 'raw' => '{bar<@uuid>}', - 'withParentheses' => null, - 'name' => 'bar', - 'pattern' => Router::$patterns['@uuid'], - 'isOptional' => false, - 'startPosition' => 14, - 'endPosition' => 25, - ], - ]; - - $this->assertSame($expected, path_parse($path)); - } - - /** - * @return void - */ - public function testParenthesesInsideParentheses() : void - { - $path = '/test(/{foo}(/{bar}))'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':12] parentheses inside parentheses are not allowed.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testBracesInsideAttributes() : void - { - $path = '/test/{foo{bar}}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':10] braces inside attributes are not allowed.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testMultipleAttributesInsideParentheses() : void - { - $path = '/test(/{foo}/{bar})'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':13] multiple attributes inside parentheses are not allowed.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testLessThanCharInsidePatterns() : void - { - $path = '/test/{foo<(?\d+)-\w+>}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':13] the char "<" inside patterns is not allowed.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testGreaterThanCharInsidePatterns() : void - { - $path = '/test/{foo<[^>]+>}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':16] at position 16 an extra char ">" was found.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testEmptyPattern() : void - { - $path = '/test/{foo<>}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':11] an attribute pattern is empty.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testExtraClosingBrace() : void - { - $path = '/test/{foo}/bar}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':15] at position 15 an extra closing brace was found.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testEmptyAttributeName() : void - { - $path = '/test/{}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':7] an attribute name is empty.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testExtraClosingParenthesis() : void - { - $path = '/test(/{foo})/bar)'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':17] at position 17 an extra closing parenthesis was found.'); - - path_parse($path); - } - - /** - * @return void - * - * @dataProvider invalidFirstCharForAttributeNameDataProvider - */ - public function testInvalidFirstCharForAttributeName($char) : void - { - $path = '/test/{' . $char . '}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':7] an attribute name must begin with "A-Za-z_".'); - - path_parse($path); - } - - /** - * @return void - * - * @dataProvider invalidSecondCharForAttributeNameDataProvider - */ - public function testInvalidSecondCharForAttributeName($char) : void - { - $path = '/test/{_' . $char . '}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':8] an attribute name must contain only "0-9A-Za-z_".'); - - path_parse($path); - } - - /** - * @return void - */ - public function testNumberSignInPattern() : void - { - $path = '/test/{foo<#>}'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . ':11] unallowed character "#" in an attribute pattern.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testNonClosedParentheses() : void - { - $path = '/test('; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . '] the route path contains non-closed parentheses.'); - - path_parse($path); - } - - /** - * @return void - */ - public function testNonClosedAttribute() : void - { - $path = '/test{'; - - $this->expectException(InvalidPathException::class); - $this->expectExceptionMessage('[' . $path . '] the route path contains non-closed attribute.'); - - path_parse($path); - } - - /** - * @return array - */ - public function invalidFirstCharForAttributeNameDataProvider() : array - { - return [ - [chr(0)], [chr(1)], [chr(2)], [chr(3)], [chr(4)], [chr(5)], [chr(6)], [chr(7)], [chr(8)], [chr(9)], - [chr(10)], [chr(11)], [chr(12)], [chr(13)], [chr(14)], [chr(15)], [chr(16)], [chr(17)], [chr(18)], - [chr(19)], [chr(20)], [chr(21)], [chr(22)], [chr(23)], [chr(24)], [chr(25)], [chr(26)], [chr(27)], - [chr(28)], [chr(29)], [chr(30)], [chr(31)], [chr(32)], [chr(33)], [chr(34)], [chr(35)], [chr(36)], - [chr(37)], [chr(38)], [chr(39)], [chr(40)], [chr(41)], [chr(42)], [chr(43)], [chr(44)], [chr(45)], - [chr(46)], [chr(47)], [chr(48)], [chr(49)], [chr(50)], [chr(51)], [chr(52)], [chr(53)], [chr(54)], - [chr(55)], [chr(56)], [chr(57)], [chr(58)], [chr(59)], [chr(61)], [chr(63)], [chr(64)], [chr(91)], - [chr(92)], [chr(93)], [chr(94)], [chr(96)], [chr(124)], [chr(126)], [chr(127)], - ]; - } - - /** - * @return array - */ - public function invalidSecondCharForAttributeNameDataProvider() : array - { - return [ - [chr(0)], [chr(1)], [chr(2)], [chr(3)], [chr(4)], [chr(5)], [chr(6)], [chr(7)], [chr(8)], [chr(9)], - [chr(10)], [chr(11)], [chr(12)], [chr(13)], [chr(14)], [chr(15)], [chr(16)], [chr(17)], [chr(18)], - [chr(19)], [chr(20)], [chr(21)], [chr(22)], [chr(23)], [chr(24)], [chr(25)], [chr(26)], [chr(27)], - [chr(28)], [chr(29)], [chr(30)], [chr(31)], [chr(32)], [chr(33)], [chr(34)], [chr(35)], [chr(36)], - [chr(37)], [chr(38)], [chr(39)], [chr(40)], [chr(41)], [chr(42)], [chr(43)], [chr(44)], [chr(45)], - [chr(46)], [chr(47)], [chr(58)], [chr(59)], [chr(61)], [chr(63)], [chr(64)], [chr(91)], [chr(92)], - [chr(93)], [chr(94)], [chr(96)], [chr(124)], [chr(126)], [chr(127)], - ]; - } -} diff --git a/tests/Functions/FunctionPathPlainTest.php b/tests/Functions/FunctionPathPlainTest.php deleted file mode 100644 index 37228af8..00000000 --- a/tests/Functions/FunctionPathPlainTest.php +++ /dev/null @@ -1,31 +0,0 @@ -}(/)'; - $expected = '/foo/{bar}/{baz}/'; - - $this->assertSame($expected, path_plain($path)); - } -} diff --git a/tests/Functions/FunctionPathRegexTest.php b/tests/Functions/FunctionPathRegexTest.php deleted file mode 100644 index a31c0fc3..00000000 --- a/tests/Functions/FunctionPathRegexTest.php +++ /dev/null @@ -1,31 +0,0 @@ -}(/)'; - $expected = '#^/foo(?:/(?[^/]+))?/(?\w+)(?:/)?$#uD'; - - $this->assertSame($expected, path_regex($path)); - } -} diff --git a/tests/Helper/RouteParserTest.php b/tests/Helper/RouteParserTest.php new file mode 100644 index 00000000..704806a2 --- /dev/null +++ b/tests/Helper/RouteParserTest.php @@ -0,0 +1,183 @@ +assertEquals($expectedVariables, $actualVariables); + } + + /** + * @dataProvider invalidRouteDataProvider + */ + public function testParseInvalidRoute(string $route, string $expectedMessageRegex): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches($expectedMessageRegex); + RouteParser::parseRoute($route); + } + + private function validRouteDataProvider(): iterable + { + yield [ + '', + [], + ]; + + yield [ + '/', + [], + ]; + + yield [ + '/posts/{id}', + [ + ['name' => 'id', 'offset' => 7, 'length' => 4], + ], + ]; + + yield [ + '/posts/{id<\d+>}', + [ + ['name' => 'id', 'pattern' => '\d+', 'offset' => 7, 'length' => 9], + ], + ]; + + yield [ + '/posts(/{id})', + [ + ['name' => 'id', 'optional' => ['left' => '/', 'right' => ''], 'offset' => 8, 'length' => 4], + ], + ]; + + yield [ + '/posts(/{id<\d+>})', + [ + + ['name' => 'id', 'pattern' => '\d+', 'optional' => ['left' => '/', 'right' => ''], 'offset' => 8, 'length' => 9], + ], + ]; + + yield [ + '/posts(/{id<\d+>}.json)', + [ + ['name' => 'id', 'pattern' => '\d+', 'optional' => ['left' => '/', 'right' => '.json'], 'offset' => 8, 'length' => 9], + ], + ]; + + yield [ + '/posts/{id<\d+>}(.json)', + [ + ['name' => 'id', 'pattern' => '\d+', 'offset' => 7, 'length' => 9], + ], + ]; + + yield [ + '(/{lang<[a-z]{2}>})/posts(/{id<\d+>})', + [ + ['name' => 'lang', 'pattern' => '[a-z]{2}', 'optional' => ['left' => '/', 'right' => ''], 'offset' => 2, 'length' => 16], + ['name' => 'id', 'pattern' => '\d+', 'optional' => ['left' => '/', 'right' => ''], 'offset' => 27, 'length' => 9], + ], + ]; + } + + private function invalidRouteDataProvider(): iterable + { + yield [ + '((', + '/nested optional parts are not supported/', + ]; + + yield [ + ')', + '/open optional part was not found/', + ]; + + yield [ + '{{', + '/nested variables are not supported/', + ]; + + yield [ + '({x}{', + '/more than one variable inside an optional part is not supported/', + ]; + + yield [ + '}', + '/open variable was not found/', + ]; + + yield [ + '{}', + '/name is required for its declaration/', + ]; + + yield [ + '{<<', + '/nested patterns are not supported/', + ]; + + yield [ + '{<.><', + '/pattern must be preceded by the variable name/', + ]; + + yield [ + '{>', + '/open pattern was not found/', + ]; + + yield [ + '{<>', + '/content is required for its declaration/', + ]; + + yield [ + '{0', + '/variable names cannot start with digits/', + ]; + + yield [ + '{!', + '/variable names must consist only of digits, letters and underscores/' + ]; + + yield [ + '{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + '/variable names must not exceed 32 characters/' + ]; + + yield [ + '/{<#/', + '/variable patterns cannot contain the character #/' + ]; + + yield [ + '{x<.>!}', + '/variable at this position must be closed/' + ]; + + yield [ + '(', + '/contains an unclosed optional part or variable/' + ]; + + yield [ + '{', + '/contains an unclosed optional part or variable/' + ]; + } +} diff --git a/tests/Loader/ConfigLoaderTest.php b/tests/Loader/ConfigLoaderTest.php deleted file mode 100644 index fa114f86..00000000 --- a/tests/Loader/ConfigLoaderTest.php +++ /dev/null @@ -1,126 +0,0 @@ -assertInstanceOf(LoaderInterface::class, $loader); - } - - /** - * @return void - */ - public function testContainer() : void - { - $container = $this->getContainer(); - - $loader = new ConfigLoader(); - $this->assertNull($loader->getContainer()); - - $loader->setContainer($container); - $this->assertSame($container, $loader->getContainer()); - - $loader->setContainer(null); - $this->assertNull($loader->getContainer()); - } - - /** - * @return void - */ - public function testAttachInvalidResource() : void - { - $loader = new ConfigLoader(); - - $this->expectException(InvalidLoaderResourceException::class); - $this->expectExceptionMessage('The resource "undefined" is not found.'); - - $loader->attach('undefined'); - } - - /** - * @return void - */ - public function testAttachArrayWithInvalidResource() : void - { - $loader = new ConfigLoader(); - - $this->expectException(InvalidLoaderResourceException::class); - $this->expectExceptionMessage('The resource "undefined" is not found.'); - - $loader->attachArray(['undefined']); - } - - /** - * @return void - * - * @runInSeparateProcess - */ - public function testLoadFile() : void - { - $loader = new ConfigLoader(); - - $loader->attach(__DIR__ . '/../Fixtures/routes/foo.php'); - - $routes = $loader->load(); - - $this->assertTrue($routes->has('foo')); - } - - /** - * @return void - * - * @runInSeparateProcess - */ - public function testLoadSeveralFiles() : void - { - $loader = new ConfigLoader(); - - $loader->attachArray([ - __DIR__ . '/../Fixtures/routes/foo.php', - __DIR__ . '/../Fixtures/routes/bar.php', - ]); - - $routes = $loader->load(); - - $this->assertTrue($routes->has('foo')); - $this->assertTrue($routes->has('bar')); - } - - /** - * @return void - * - * @runInSeparateProcess - */ - public function testLoadDirectory() : void - { - $loader = new ConfigLoader(); - - $loader->attach(__DIR__ . '/../Fixtures/routes'); - - $routes = $loader->load(); - - $this->assertTrue($routes->has('foo')); - $this->assertTrue($routes->has('bar')); - } -} diff --git a/tests/Loader/DescriptorLoaderTest.php b/tests/Loader/DescriptorLoaderTest.php deleted file mode 100644 index 929e29f9..00000000 --- a/tests/Loader/DescriptorLoaderTest.php +++ /dev/null @@ -1,366 +0,0 @@ -assertInstanceOf(LoaderInterface::class, $loader); - } - - /** - * @return void - */ - public function testContainer() : void - { - $container = $this->getContainer(); - - $loader = new DescriptorLoader(); - $this->assertNull($loader->getContainer()); - - $loader->setContainer($container); - $this->assertSame($container, $loader->getContainer()); - - $loader->setContainer(null); - $this->assertNull($loader->getContainer()); - } - - /** - * @return void - */ - public function testCache() : void - { - $cache = $this->getCache(); - - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Annotated\CacheableAnnotatedController::class); - - $this->assertNull($loader->getCache()); - $this->assertNull($loader->getCacheKey()); - - $loader->setCache($cache); - $this->assertSame($cache, $loader->getCache()); - - $loader->setCacheKey('foo'); - $this->assertSame('foo', $loader->getCacheKey()); - - $descriptor = new Route('controller-from-cached-descriptor', null, '/'); - $descriptor->holder = Fixtures\Controllers\BlankController::class; - - $cache->storage[$loader->getCacheKey()][0] = $descriptor; - - $routes = $loader->load(); - $this->assertTrue($routes->has($cache->storage[$loader->getCacheKey()][0]->name)); - - $loader->setCache(null); - $this->assertNull($loader->getCache()); - - $loader->setCacheKey(null); - $this->assertNull($loader->getCacheKey()); - } - - /** - * @return void - */ - public function testAttachInvalidResource() : void - { - $loader = new DescriptorLoader(); - - $this->expectException(InvalidLoaderResourceException::class); - $this->expectExceptionMessage('The resource "undefined" is not found.'); - - $loader->attach('undefined'); - } - - /** - * @return void - */ - public function testAttachArrayWithInvalidResource() : void - { - $loader = new DescriptorLoader(); - - $this->expectException(InvalidLoaderResourceException::class); - $this->expectExceptionMessage('The resource "undefined" is not found.'); - - $loader->attachArray(['undefined']); - } - - /** - * @return void - */ - public function testLoadMinimallyAnnotatedClass() : void - { - $class = Fixtures\Controllers\Annotated\MinimallyAnnotatedController::class; - - $loader = new DescriptorLoader(); - $loader->attach($class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('minimally-annotated-controller')); - - $route = $routes->get('minimally-annotated-controller'); - $this->assertSame('minimally-annotated-controller', $route->getName()); - $this->assertSame('/', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - } - - /** - * @return void - */ - public function testLoadMinimallyAttributedClass() : void - { - if (8 > PHP_MAJOR_VERSION) { - $this->markTestSkipped('PHP 8 is required...'); - return; - } - - $class = Fixtures\Controllers\Attributed\MinimallyAttributedController::class; - - $loader = new DescriptorLoader(); - $loader->attach($class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('minimally-attributed-controller')); - - $route = $routes->get('minimally-attributed-controller'); - $this->assertSame('minimally-attributed-controller', $route->getName()); - $this->assertSame('/', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - } - - /** - * @return void - */ - public function testLoadMaximallyAnnotatedClass() : void - { - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Annotated\MaximallyAnnotatedController::class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('maximally-annotated-controller')); - - $route = $routes->get('maximally-annotated-controller'); - $this->assertSame('maximally-annotated-controller', $route->getName()); - $this->assertSame('local', $route->getHost()); - $this->assertSame('/', $route->getPath()); - $this->assertSame(['HEAD', 'GET'], $route->getMethods()); - $this->assertCount(1, $route->getMiddlewares()); - $this->assertInstanceOf(Fixtures\Middlewares\BlankMiddleware::class, $route->getMiddlewares()[0]); - $this->assertSame(['foo' => 'bar'], $route->getAttributes()); - $this->assertSame('Lorem ipsum', $route->getSummary()); - $this->assertSame('Lorem ipsum dolor sit amet', $route->getDescription()); - $this->assertSame(['foo', 'bar'], $route->getTags()); - } - - /** - * @return void - */ - public function testLoadMaximallyAttributedClass() : void - { - if (8 > PHP_MAJOR_VERSION) { - $this->markTestSkipped('PHP 8 is required...'); - return; - } - - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Attributed\MaximallyAttributedController::class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('maximally-attributed-controller')); - - $route = $routes->get('maximally-attributed-controller'); - $this->assertSame('maximally-attributed-controller', $route->getName()); - $this->assertSame('local', $route->getHost()); - $this->assertSame('/', $route->getPath()); - $this->assertSame(['HEAD', 'GET'], $route->getMethods()); - $this->assertCount(1, $route->getMiddlewares()); - $this->assertInstanceOf(Fixtures\Middlewares\BlankMiddleware::class, $route->getMiddlewares()[0]); - $this->assertSame(['foo' => 'bar'], $route->getAttributes()); - $this->assertSame('Lorem ipsum', $route->getSummary()); - $this->assertSame('Lorem ipsum dolor sit amet', $route->getDescription()); - $this->assertSame(['foo', 'bar'], $route->getTags()); - } - - /** - * @return void - */ - public function testLoadGroupedAnnotatedClass() : void - { - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Annotated\GroupedAnnotatedController::class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('first-from-grouped-annotated-controller')); - $this->assertTrue($routes->has('second-from-grouped-annotated-controller')); - $this->assertTrue($routes->has('third-from-grouped-annotated-controller')); - - $route = $routes->get('first-from-grouped-annotated-controller'); - $this->assertSame('host', $route->getHost()); - $this->assertSame('/prefix/first.json', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - $this->assertCount(6, $route->getMiddlewares()); - - $route = $routes->get('second-from-grouped-annotated-controller'); - $this->assertSame('host', $route->getHost()); - $this->assertSame('/prefix/second.json', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - $this->assertCount(6, $route->getMiddlewares()); - - $route = $routes->get('third-from-grouped-annotated-controller'); - $this->assertSame('host', $route->getHost()); - $this->assertSame('/prefix/third.json', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - $this->assertCount(6, $route->getMiddlewares()); - - $this->assertFalse($routes->has('private-from-grouped-annotated-controller')); - $this->assertFalse($routes->has('protected-from-grouped-annotated-controller')); - $this->assertFalse($routes->has('static-from-grouped-annotated-controller')); - } - - /** - * @return void - */ - public function testLoadGroupedAttributedClass() : void - { - if (8 > PHP_MAJOR_VERSION) { - $this->markTestSkipped('PHP 8 is required...'); - return; - } - - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Attributed\GroupedAttributedController::class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('first-from-grouped-attributed-controller')); - $this->assertTrue($routes->has('second-from-grouped-attributed-controller')); - $this->assertTrue($routes->has('third-from-grouped-attributed-controller')); - - $route = $routes->get('first-from-grouped-attributed-controller'); - $this->assertSame('host', $route->getHost()); - $this->assertSame('/prefix/first.json', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - $this->assertCount(6, $route->getMiddlewares()); - - $route = $routes->get('second-from-grouped-attributed-controller'); - $this->assertSame('host', $route->getHost()); - $this->assertSame('/prefix/second.json', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - $this->assertCount(6, $route->getMiddlewares()); - - $route = $routes->get('third-from-grouped-attributed-controller'); - $this->assertSame('host', $route->getHost()); - $this->assertSame('/prefix/third.json', $route->getPath()); - $this->assertSame(['GET'], $route->getMethods()); - $this->assertCount(6, $route->getMiddlewares()); - } - - /** - * @return void - */ - public function testLoadSeveralAnnotatedClasses() : void - { - $loader = new DescriptorLoader(); - $loader->attachArray([ - Fixtures\Controllers\Annotated\MinimallyAnnotatedController::class, - Fixtures\Controllers\Annotated\MaximallyAnnotatedController::class, - ]); - - $routes = $loader->load(); - $this->assertTrue($routes->has('minimally-annotated-controller')); - $this->assertTrue($routes->has('maximally-annotated-controller')); - } - - /** - * @return void - */ - public function testLoadDirectoryWithAnnotatedClasses() : void - { - $loader = new DescriptorLoader(); - $loader->attach(__DIR__ . '/../Fixtures/Controllers/Annotated/Loadable'); - - $routes = $loader->load(); - $this->assertTrue($routes->has('first-loadable-annotated-controller')); - $this->assertTrue($routes->has('second-loadable-annotated-controller')); - } - - /** - * @return void - */ - public function testLoadSortableAnnotatedClasses() : void - { - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Annotated\Sortable\FirstSortableAnnotatedController::class); - $loader->attach(Fixtures\Controllers\Annotated\Sortable\SecondSortableAnnotatedController::class); - $loader->attach(Fixtures\Controllers\Annotated\Sortable\ThirdSortableAnnotatedController::class); - - $routes = $loader->load(); - $this->assertTrue($routes->has('first-sortable-annotated-controller')); - $this->assertTrue($routes->has('second-sortable-annotated-controller')); - $this->assertTrue($routes->has('third-sortable-annotated-controller')); - - $this->assertSame([ - 'third-sortable-annotated-controller', - 'second-sortable-annotated-controller', - 'first-sortable-annotated-controller', - ], array_map(function ($route) { - return $route->getName(); - }, $routes->all())); - } - - /** - * @return void - */ - public function testLoadAbstractAnnotatedClass() : void - { - $loader = new DescriptorLoader(); - $loader->attach(Fixtures\Controllers\Annotated\AbstractAnnotatedController::class); - - $routes = $loader->load(); - $this->assertFalse($routes->has('abstract-annotated-controller')); - } -} diff --git a/tests/Middleware/CallableMiddlewareTest.php b/tests/Middleware/CallableMiddlewareTest.php deleted file mode 100644 index f1e6b029..00000000 --- a/tests/Middleware/CallableMiddlewareTest.php +++ /dev/null @@ -1,47 +0,0 @@ -assertInstanceOf(MiddlewareInterface::class, $middleware); - } - - /** - * @return void - */ - public function testRun() : void - { - $callback = new Fixtures\Middlewares\BlankMiddleware(); - $middleware = new CallableMiddleware($callback); - - $this->assertSame($callback, $middleware->getCallback()); - - $request = $this->createMock(ServerRequestInterface::class); - $requestHandler = new Fixtures\Controllers\BlankController(); - $middleware->process($request, $requestHandler); - - $this->assertTrue($callback->isRunned()); - } -} diff --git a/tests/Middleware/JsonPayloadDecodingMiddlewareTest.php b/tests/Middleware/JsonPayloadDecodingMiddlewareTest.php deleted file mode 100644 index 7d4fa750..00000000 --- a/tests/Middleware/JsonPayloadDecodingMiddlewareTest.php +++ /dev/null @@ -1,130 +0,0 @@ -assertInstanceOf(MiddlewareInterface::class, new JsonPayloadDecodingMiddleware()); - } - - /** - * @param string $mediaType - * - * @return void - * - * @dataProvider supportedMediaTypeProvider - */ - public function testProcessWithSupportedMediaType(string $mediaType) : void - { - $request = (new ServerRequestFactory)->createServerRequest('GET', '/') - ->withHeader('Content-Type', $mediaType); - - $request->getBody()->write('{"foo":"bar"}'); - - $handler = new Fixtures\Controllers\BlankController(); - - (new JsonPayloadDecodingMiddleware)->process($request, $handler); - - $this->assertSame(['foo' => 'bar'], $handler->getRequest()->getParsedBody()); - } - - /** - * @param string $mediaType - * - * @return void - * - * @dataProvider unsupportedMediaTypeProvider - */ - public function testProcessWithUnsupportedMediaType(string $mediaType) : void - { - $request = (new ServerRequestFactory)->createServerRequest('GET', '/') - ->withHeader('Content-Type', $mediaType); - - $request->getBody()->write('{"foo":"bar"}'); - - $handler = new Fixtures\Controllers\BlankController(); - - (new JsonPayloadDecodingMiddleware)->process($request, $handler); - - $this->assertNull($handler->getRequest()->getParsedBody()); - } - - /** - * @return void - */ - public function testProcessWithoutMediaType() : void - { - $request = (new ServerRequestFactory)->createServerRequest('GET', '/'); - $request->getBody()->write('{"foo":"bar"}'); - - $handler = new Fixtures\Controllers\BlankController(); - - (new JsonPayloadDecodingMiddleware)->process($request, $handler); - - $this->assertNull($handler->getRequest()->getParsedBody()); - } - - /** - * @return void - */ - public function testProcessWithInvalidPayload() : void - { - $request = (new ServerRequestFactory)->createServerRequest('GET', '/') - ->withHeader('Content-Type', 'application/json'); - - $request->getBody()->write('!'); - - $handler = new Fixtures\Controllers\BlankController(); - - $this->expectException(UndecodablePayloadException::class); - $this->expectExceptionMessage('Invalid Payload: Syntax error'); - - (new JsonPayloadDecodingMiddleware)->process($request, $handler); - } - - /** - * @return list - */ - public function supportedMediaTypeProvider() : array - { - return [ - ['application/json'], - ['application/json; foo=bar'], - ['application/json ; foo=bar'], - ]; - } - - /** - * @return list - */ - public function unsupportedMediaTypeProvider() : array - { - return [ - ['application/jsonx'], - ['application/json+x'], - ['application/jsonx; foo=bar'], - ['application/json+x; foo=bar'], - ['application/jsonx ; foo=bar'], - ['application/json+x ; foo=bar'], - ]; - } -} diff --git a/tests/ReferenceResolverTest.php b/tests/ReferenceResolverTest.php deleted file mode 100644 index e3d606de..00000000 --- a/tests/ReferenceResolverTest.php +++ /dev/null @@ -1,169 +0,0 @@ -assertInstanceOf(ReferenceResolverInterface::class, $resolver); - } - - /** - * @return void - */ - public function testContainer() : void - { - $container = $this->getContainer([ - Fixtures\Controllers\BlankController::class => new Fixtures\Controllers\BlankController(), - Fixtures\Middlewares\BlankMiddleware::class => new Fixtures\Middlewares\BlankMiddleware(), - ]); - - $resolver = new ReferenceResolver(); - - $resolver->setContainer($container); - $this->assertSame($container, $resolver->getContainer()); - - $reference = Fixtures\Controllers\BlankController::class; - $requestHandler = $resolver->toRequestHandler($reference); - $this->assertSame($container->storage[Fixtures\Controllers\BlankController::class], $requestHandler); - - $reference = [Fixtures\Controllers\BlankController::class, '__invoke']; - $requestHandler = $resolver->toRequestHandler($reference); - $requestHandlerCallback = $requestHandler->getCallback(); - $this->assertSame($container->storage[Fixtures\Controllers\BlankController::class], $requestHandlerCallback[0]); - - $reference = Fixtures\Middlewares\BlankMiddleware::class; - $middleware = $resolver->toMiddleware($reference); - $this->assertSame($container->storage[Fixtures\Middlewares\BlankMiddleware::class], $middleware); - - $reference = [Fixtures\Middlewares\BlankMiddleware::class, '__invoke']; - $middleware = $resolver->toMiddleware($reference); - $middlewareCallback = $middleware->getCallback(); - $this->assertSame($container->storage[Fixtures\Middlewares\BlankMiddleware::class], $middlewareCallback[0]); - } - - /** - * @return void - */ - public function testRequestHandler() : void - { - $resolver = new ReferenceResolver(); - - $reference = new Fixtures\Controllers\BlankController(); - $requestHandler = $resolver->toRequestHandler($reference); - $this->assertSame($reference, $requestHandler); - - $reference = function () { - }; - - $requestHandler = $resolver->toRequestHandler($reference); - $this->assertSame($reference, $requestHandler->getCallback()); - - $reference = Fixtures\Controllers\BlankController::class; - $requestHandler = $resolver->toRequestHandler($reference); - $this->assertInstanceOf($reference, $requestHandler); - - $reference = [Fixtures\Controllers\BlankController::class, '__invoke']; - $requestHandler = $resolver->toRequestHandler($reference); - $this->assertInstanceOf($reference[0], $requestHandler->getCallback()[0]); - $this->assertSame($reference[1], $requestHandler->getCallback()[1]); - } - - /** - * @return void - */ - public function testMiddleware() : void - { - $resolver = new ReferenceResolver(); - - $reference = new Fixtures\Middlewares\BlankMiddleware(); - $requestHandler = $resolver->toMiddleware($reference); - $this->assertSame($reference, $requestHandler); - - $reference = function () { - }; - - $requestHandler = $resolver->toMiddleware($reference); - $this->assertSame($reference, $requestHandler->getCallback()); - - $reference = Fixtures\Middlewares\BlankMiddleware::class; - $requestHandler = $resolver->toMiddleware($reference); - $this->assertInstanceOf($reference, $requestHandler); - - $reference = [Fixtures\Middlewares\BlankMiddleware::class, '__invoke']; - $requestHandler = $resolver->toMiddleware($reference); - $this->assertInstanceOf($reference[0], $requestHandler->getCallback()[0]); - $this->assertSame($reference[1], $requestHandler->getCallback()[1]); - } - - /** - * @param mixed $reference - * - * @return void - * - * @dataProvider unresolvableRequestHandlerReferenceDataProvider - */ - public function testUnresolvableRequestHandler($reference) : void - { - $resolver = new ReferenceResolver(); - $this->expectException(UnresolvableReferenceException::class); - $resolver->toRequestHandler($reference); - } - - /** - * @param mixed $reference - * - * @return void - * - * @dataProvider unresolvableMiddlewareReferenceDataProvider - */ - public function testUnresolvableMiddleware($reference) : void - { - $resolver = new ReferenceResolver(); - $this->expectException(UnresolvableReferenceException::class); - $resolver->toMiddleware($reference); - } - - /** - * @return array - */ - public function unresolvableRequestHandlerReferenceDataProvider() : array - { - return [ - [['unknownClass', 'unknownMethod']], - ['unknownClass'], - [null], - ]; - } - - /** - * @return array - */ - public function unresolvableMiddlewareReferenceDataProvider() : array - { - return [ - [['unknownClass', 'unknownMethod']], - ['unknownClass'], - [null], - ]; - } -} diff --git a/tests/RequestHandler/CallableRequestHandlerTest.php b/tests/RequestHandler/CallableRequestHandlerTest.php deleted file mode 100644 index 5ae1ac19..00000000 --- a/tests/RequestHandler/CallableRequestHandlerTest.php +++ /dev/null @@ -1,46 +0,0 @@ -assertInstanceOf(RequestHandlerInterface::class, $requestHandler); - } - - /** - * @return void - */ - public function testRun() : void - { - $callback = new Fixtures\Controllers\BlankController(); - $requestHandler = new CallableRequestHandler($callback); - - $this->assertSame($callback, $requestHandler->getCallback()); - - $request = $this->createMock(ServerRequestInterface::class); - $requestHandler->handle($request); - - $this->assertTrue($callback->isRunned()); - } -} diff --git a/tests/RequestHandler/QueueableRequestHandlerTest.php b/tests/RequestHandler/QueueableRequestHandlerTest.php deleted file mode 100644 index 395e3f89..00000000 --- a/tests/RequestHandler/QueueableRequestHandlerTest.php +++ /dev/null @@ -1,92 +0,0 @@ -assertInstanceOf(RequestHandlerInterface::class, $requestHandler); - } - - /** - * @return void - */ - public function testRun() : void - { - $endpoint = new Fixtures\Controllers\BlankController(); - $requestHandler = new QueueableRequestHandler($endpoint); - - $request = $this->createMock(ServerRequestInterface::class); - $requestHandler->handle($request); - - $this->assertTrue($endpoint->isRunned()); - } - - /** - * @return void - */ - public function testRunWithMiddlewares() : void - { - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $endpoint = new Fixtures\Controllers\BlankController(); - $requestHandler = new QueueableRequestHandler($endpoint); - $requestHandler->add(...$middlewares); - - $request = $this->createMock(ServerRequestInterface::class); - $requestHandler->handle($request); - - $this->assertTrue($middlewares[0]->isRunned()); - $this->assertTrue($middlewares[1]->isRunned()); - $this->assertTrue($middlewares[2]->isRunned()); - $this->assertTrue($endpoint->isRunned()); - } - - /** - * @return void - */ - public function testRunWithBrokenMiddleware() : void - { - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(true), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $endpoint = new Fixtures\Controllers\BlankController(); - $requestHandler = new QueueableRequestHandler($endpoint); - $requestHandler->add(...$middlewares); - - $request = $this->createMock(ServerRequestInterface::class); - $requestHandler->handle($request); - - $this->assertTrue($middlewares[0]->isRunned()); - $this->assertTrue($middlewares[1]->isRunned()); - $this->assertFalse($middlewares[2]->isRunned()); - $this->assertFalse($endpoint->isRunned()); - } -} diff --git a/tests/RouteCollectionFactoryTest.php b/tests/RouteCollectionFactoryTest.php deleted file mode 100644 index 780ed6be..00000000 --- a/tests/RouteCollectionFactoryTest.php +++ /dev/null @@ -1,54 +0,0 @@ -assertInstanceOf(RouteCollectionFactoryInterface::class, $factory); - } - - /** - * @return void - */ - public function testCreateCollection() : void - { - $collection = (new RouteCollectionFactory)->createCollection(); - - $this->assertInstanceOf(RouteCollectionInterface::class, $collection); - } - - /** - * @return void - */ - public function testCreateCollectionWithRoutes() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $collection = (new RouteCollectionFactory)->createCollection(...$routes); - - $this->assertSame($routes, $collection->all()); - } -} diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php deleted file mode 100644 index 5b17b3c3..00000000 --- a/tests/RouteCollectionTest.php +++ /dev/null @@ -1,333 +0,0 @@ -assertInstanceOf(RouteCollectionInterface::class, $collection); - } - - /** - * @return void - */ - public function testConstructor() : void - { - $collection = new RouteCollection(); - - $this->assertSame([], $collection->all()); - } - - /** - * @return void - */ - public function testConstructorWithRoutes() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $collection = new RouteCollection(...$routes); - - $this->assertSame($routes, $collection->all()); - } - - /** - * @return void - */ - public function testAdd() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $collection = new RouteCollection(); - $collection->add(...$routes); - - $this->assertSame($routes, $collection->all()); - } - - /** - * @return void - */ - public function testGet() : void - { - $route = new Fixtures\Route(); - $collection = new RouteCollection(); - - $this->assertNull($collection->get($route->getName())); - - $collection->add($route); - - $this->assertSame($route, $collection->get($route->getName())); - } - - /** - * @return void - */ - public function testHas() : void - { - $route = new Fixtures\Route(); - $collection = new RouteCollection(); - - $this->assertFalse($collection->has($route->getName())); - - $collection->add($route); - - $this->assertTrue($collection->has($route->getName())); - } - - /** - * @return void - */ - public function testSetHost() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $collection = new RouteCollection(...$routes); - $collection->setHost('google.com'); - - $this->assertSame('google.com', $routes[0]->getHost()); - $this->assertSame('google.com', $routes[1]->getHost()); - $this->assertSame('google.com', $routes[2]->getHost()); - } - - /** - * @return void - */ - public function testAddPrefix() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $routes[0]->setPath('/foo'); - $routes[1]->setPath('/bar'); - $routes[2]->setPath('/baz'); - - $collection = new RouteCollection(...$routes); - $collection->addPrefix('/api'); - - $this->assertSame('/api/foo', $routes[0]->getPath()); - $this->assertSame('/api/bar', $routes[1]->getPath()); - $this->assertSame('/api/baz', $routes[2]->getPath()); - } - - /** - * @return void - */ - public function testAddSuffix() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $routes[0]->setPath('/foo'); - $routes[1]->setPath('/bar'); - $routes[2]->setPath('/baz'); - - $collection = new RouteCollection(...$routes); - $collection->addSuffix('.json'); - - $this->assertSame('/foo.json', $routes[0]->getPath()); - $this->assertSame('/bar.json', $routes[1]->getPath()); - $this->assertSame('/baz.json', $routes[2]->getPath()); - } - - /** - * @return void - */ - public function testAddMethod() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $routes[0]->setMethods('FOO'); - $routes[1]->setMethods('BAR'); - $routes[2]->setMethods('BAZ'); - - $collection = new RouteCollection(...$routes); - $collection->addMethod('QUX', 'QUUX'); - - $this->assertSame(['FOO', 'QUX', 'QUUX'], $routes[0]->getMethods()); - $this->assertSame(['BAR', 'QUX', 'QUUX'], $routes[1]->getMethods()); - $this->assertSame(['BAZ', 'QUX', 'QUUX'], $routes[2]->getMethods()); - } - - /** - * @return void - */ - public function testAddMiddleware() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $additionals = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $routes[0]->setMiddlewares(...$middlewares); - $routes[1]->setMiddlewares(...$middlewares); - $routes[2]->setMiddlewares(...$middlewares); - - $collection = new RouteCollection(...$routes); - $collection->addMiddleware(...$additionals); - - $this->assertSame(array_merge($middlewares, $additionals), $routes[0]->getMiddlewares()); - $this->assertSame(array_merge($middlewares, $additionals), $routes[1]->getMiddlewares()); - $this->assertSame(array_merge($middlewares, $additionals), $routes[2]->getMiddlewares()); - } - - /** - * @return void - */ - public function testAppendMiddleware() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $additionals = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $routes[0]->setMiddlewares(...$middlewares); - $routes[1]->setMiddlewares(...$middlewares); - $routes[2]->setMiddlewares(...$middlewares); - - $collection = new RouteCollection(...$routes); - $collection->appendMiddleware(...$additionals); - - $this->assertSame(array_merge($middlewares, $additionals), $routes[0]->getMiddlewares()); - $this->assertSame(array_merge($middlewares, $additionals), $routes[1]->getMiddlewares()); - $this->assertSame(array_merge($middlewares, $additionals), $routes[2]->getMiddlewares()); - } - - /** - * @return void - */ - public function testPrependMiddleware() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $additionals = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $routes[0]->setMiddlewares(...$middlewares); - $routes[1]->setMiddlewares(...$middlewares); - $routes[2]->setMiddlewares(...$middlewares); - - $collection = new RouteCollection(...$routes); - $collection->prependMiddleware(...$additionals); - - $this->assertSame(array_merge($additionals, $middlewares), $routes[0]->getMiddlewares()); - $this->assertSame(array_merge($additionals, $middlewares), $routes[1]->getMiddlewares()); - $this->assertSame(array_merge($additionals, $middlewares), $routes[2]->getMiddlewares()); - } - - /** - * @return void - */ - public function testUnshiftMiddleware() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $additionals = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $routes[0]->setMiddlewares(...$middlewares); - $routes[1]->setMiddlewares(...$middlewares); - $routes[2]->setMiddlewares(...$middlewares); - - $collection = new RouteCollection(...$routes); - $collection->unshiftMiddleware(...$additionals); - - $this->assertSame(array_merge($additionals, $middlewares), $routes[0]->getMiddlewares()); - $this->assertSame(array_merge($additionals, $middlewares), $routes[1]->getMiddlewares()); - $this->assertSame(array_merge($additionals, $middlewares), $routes[2]->getMiddlewares()); - } -} diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php deleted file mode 100644 index 5247b360..00000000 --- a/tests/RouteCollectorTest.php +++ /dev/null @@ -1,353 +0,0 @@ -getContainer(); - - $collector = new RouteCollector(); - $this->assertNull($collector->getContainer()); - - $collector->setContainer($container); - $this->assertSame($container, $collector->getContainer()); - - $collector->setContainer(null); - $this->assertNull($collector->getContainer()); - } - - /** - * @return void - */ - public function testCreateRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $methods = ['GET', 'POST']; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->route( - $name, - $path, - $methods, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame($methods, $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreateHeadRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->head( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_HEAD], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreateGetRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->get( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_GET], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreatePostRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->post( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_POST], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreatePutRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->put( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_PUT], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreatePatchRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->patch( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_PATCH], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreateDeleteRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->delete( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_DELETE], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testCreatePurgeRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = []; - $attributes['foo'] = 'bar'; - - $collector = new RouteCollector(); - - $route = $collector->purge( - $name, - $path, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame([Router::METHOD_PURGE], $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - $this->assertTrue($collector->getCollection()->has($route->getName())); - $this->assertSame($route, $collector->getCollection()->get($route->getName())); - } - - /** - * @return void - */ - public function testGrouping() : void - { - $collector = new RouteCollector(); - - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - - $groupCollection = $collector->group(function ($group) use ($middlewares) { - $this->assertInstanceOf(RouteCollector::class, $group); - - $group->get('foo', '/foo', new Fixtures\Controllers\BlankController()); - - $group->group(function ($subgroup) { - $subgroup->get('bar', '/bar', new Fixtures\Controllers\BlankController()); - - $subgroup->group(function ($deepgroup) { - $deepgroup->get('baz', '/baz', new Fixtures\Controllers\BlankController()); - }); - }, $middlewares); - }); - - $this->assertInstanceOf(RouteCollectionInterface::class, $groupCollection); - $this->assertTrue($collector->getCollection()->has('foo')); - $this->assertSame([], $collector->getCollection()->get('foo')->getMiddlewares()); - $this->assertTrue($collector->getCollection()->has('bar')); - $this->assertSame($middlewares, $collector->getCollection()->get('bar')->getMiddlewares()); - $this->assertTrue($collector->getCollection()->has('baz')); - $this->assertSame($middlewares, $collector->getCollection()->get('baz')->getMiddlewares()); - } -} diff --git a/tests/RouteFactoryTest.php b/tests/RouteFactoryTest.php deleted file mode 100644 index 804940ef..00000000 --- a/tests/RouteFactoryTest.php +++ /dev/null @@ -1,61 +0,0 @@ -assertInstanceOf(RouteFactoryInterface::class, $factory); - } - - /** - * @return void - */ - public function testCreateRoute() : void - { - $name = 'foo'; - $path = '/foo'; - $methods = ['GET', 'POST']; - $handler = new Fixtures\Controllers\BlankController(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $attributes = ['foo' => 'bar']; - - $route = (new RouteFactory)->createRoute( - $name, - $path, - $methods, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame($methods, $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - } -} diff --git a/tests/RouteTest.php b/tests/RouteTest.php deleted file mode 100644 index e87dbabb..00000000 --- a/tests/RouteTest.php +++ /dev/null @@ -1,414 +0,0 @@ - 'bar']; - - $route = new Route( - $name, - $path, - $methods, - $handler, - $middlewares, - $attributes - ); - - $this->assertInstanceOf(RouteInterface::class, $route); - $this->assertSame($name, $route->getName()); - $this->assertSame($path, $route->getPath()); - $this->assertSame($methods, $route->getMethods()); - $this->assertSame($handler, $route->getRequestHandler()); - $this->assertSame($middlewares, $route->getMiddlewares()); - $this->assertSame($attributes, $route->getAttributes()); - } - - /** - * @return void - */ - public function testSetName() : void - { - $route = new Fixtures\Route(); - $name = 'foo'; - $this->assertNotSame($route->getName(), $name); - $this->assertSame($route, $route->setName($name)); - $this->assertSame($name, $route->getName()); - } - - /** - * @return void - */ - public function testSetHost() : void - { - $route = new Fixtures\Route(); - $host = 'localhost'; - $this->assertNull($route->getHost()); - $this->assertSame($route, $route->setHost($host)); - $this->assertSame($host, $route->getHost()); - } - - /** - * @return void - */ - public function testSetPath() : void - { - $route = new Fixtures\Route(); - $path = '/foo'; - $this->assertNotSame($route->getPath(), $path); - $this->assertSame($route, $route->setPath($path)); - $this->assertSame($path, $route->getPath()); - } - - /** - * @return void - */ - public function testSetMethods() : void - { - $route = new Fixtures\Route(); - $methods = ['GET', 'POST']; - $this->assertNotSame($route->getMethods(), $methods); - $this->assertSame($route, $route->setMethods(...$methods)); - $this->assertSame($methods, $route->getMethods()); - } - - /** - * @return void - */ - public function testSetRequestHandler() : void - { - $route = new Fixtures\Route(); - $handler = new Fixtures\Controllers\BlankController(); - $this->assertNotSame($route->getRequestHandler(), $handler); - $this->assertSame($route, $route->setRequestHandler($handler)); - $this->assertSame($handler, $route->getRequestHandler()); - } - - /** - * @return void - */ - public function testSetMiddlewares() : void - { - $route = new Fixtures\Route(); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $this->assertNotSame($route->getMiddlewares(), $middlewares); - $this->assertSame($route, $route->setMiddlewares(...$middlewares)); - $this->assertSame($middlewares, $route->getMiddlewares()); - } - - /** - * @return void - */ - public function testSetAttributes() : void - { - $route = new Fixtures\Route(); - $attributes = ['foo' => 'bar']; - $this->assertSame([], $route->getAttributes()); - $this->assertSame($route, $route->setAttributes($attributes)); - $this->assertSame($attributes, $route->getAttributes()); - } - - /** - * @return void - */ - public function testSetSummary() : void - { - $route = new Fixtures\Route(); - $summary = 'foo bar'; - $this->assertSame('', $route->getSummary()); - $this->assertSame($route, $route->setSummary($summary)); - $this->assertSame($summary, $route->getSummary()); - } - - /** - * @return void - */ - public function testSetDescription() : void - { - $route = new Fixtures\Route(); - $description = 'foo bar'; - $this->assertSame('', $route->getDescription()); - $this->assertSame($route, $route->setDescription($description)); - $this->assertSame($description, $route->getDescription()); - } - - /** - * @return void - */ - public function testSetTags() : void - { - $route = new Fixtures\Route(); - $tags = ['foo', 'bar']; - $this->assertSame([], $route->getTags()); - $this->assertSame($route, $route->setTags(...$tags)); - $this->assertSame($tags, $route->getTags()); - } - - /** - * @return void - */ - public function testAddPrefix() : void - { - $route = new Fixtures\Route(); - $route->setPath('/bar'); - $prefix = '/foo'; - $expected = $prefix . $route->getPath(); - $this->assertSame($route, $route->addPrefix($prefix)); - $this->assertSame($expected, $route->getPath()); - } - - /** - * @return void - */ - public function testAddSuffix() : void - { - $route = new Fixtures\Route(); - $route->setPath('/foo'); - $suffix = '.bar'; - $expected = $route->getPath() . $suffix; - $this->assertSame($route, $route->addSuffix($suffix)); - $this->assertSame($expected, $route->getPath()); - } - - /** - * @return void - */ - public function testAddMethod() : void - { - $route = new Fixtures\Route(); - $route->setMethods('FOO'); - $methods = ['BAR', 'BAZ']; - $expected = array_merge($route->getMethods(), $methods); - $this->assertSame($route, $route->addMethod(...$methods)); - $this->assertSame($expected, $route->getMethods()); - } - - /** - * @return void - */ - public function testAddMiddleware() : void - { - $route = new Fixtures\Route(); - $route->setMiddlewares(new Fixtures\Middlewares\BlankMiddleware()); - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $expected = array_merge($route->getMiddlewares(), $middlewares); - $this->assertSame($route, $route->addMiddleware(...$middlewares)); - $this->assertSame($expected, $route->getMiddlewares()); - } - - /** - * @return void - */ - public function testWithAddedAttributes() : void - { - $route = new Fixtures\Route(); - $route->setAttributes(['foo' => 'bar']); - $attributes = ['bar' => 'baz']; - $expected = $route->getAttributes() + $attributes; - $clone = $route->withAddedAttributes($attributes); - $this->assertNotSame($expected, $route->getAttributes()); - $this->assertInstanceOf(RouteInterface::class, $clone); - $this->assertSame($expected, $clone->getAttributes()); - $this->assertNotSame($route, $clone); - } - - /** - * @return void - */ - public function testAddSlashEndingPrefix() : void - { - $route = new Fixtures\Route(); - $route->setPath('/bar'); - $route->addPrefix('/foo/'); - $this->assertSame('/foo/bar', $route->getPath()); - } - - /** - * @return void - */ - public function testSetLowercasedMethods() : void - { - $route = new Fixtures\Route(); - $route->setMethods('foo', 'bar'); - $this->assertSame(['FOO', 'BAR'], $route->getMethods()); - } - - /** - * @return void - */ - public function testAddLowercasedMethod() : void - { - $route = new Fixtures\Route(); - $route->setMethods(...[]); // clear previous methods... - $route->addMethod('foo', 'bar'); - $this->assertSame(['FOO', 'BAR'], $route->getMethods()); - } - - /** - * @return void - */ - public function testRun() : void - { - $route = new Fixtures\Route(); - $route->handle((new ServerRequestFactory)->createServerRequest('GET', '/')); - - $this->assertTrue($route->getRequestHandler()->isRunned()); - - $this->assertSame([ - Route::ATTR_NAME_FOR_ROUTE => $route, - Route::ATTR_NAME_FOR_ROUTE_NAME => $route->getName(), - ], $route->getRequestHandler()->getRequest()->getAttributes()); - } - - /** - * @return void - */ - public function testRunWithAttributes() : void - { - $route = new Fixtures\Route(); - $route->setAttributes(['foo' => 'bar']); - $route->handle((new ServerRequestFactory)->createServerRequest('GET', '/')); - - $this->assertTrue($route->getRequestHandler()->isRunned()); - - $this->assertSame([ - Route::ATTR_NAME_FOR_ROUTE => $route, - Route::ATTR_NAME_FOR_ROUTE_NAME => $route->getName(), - ] + $route->getAttributes(), $route->getRequestHandler()->getRequest()->getAttributes()); - } - - /** - * @return void - */ - public function testRunWithMiddlewares() : void - { - $route = new Fixtures\Route(); - $route->addMiddleware(new Fixtures\Middlewares\BlankMiddleware()); - $route->addMiddleware(new Fixtures\Middlewares\BlankMiddleware()); - $route->addMiddleware(new Fixtures\Middlewares\BlankMiddleware()); - $route->handle((new ServerRequestFactory)->createServerRequest('GET', '/')); - - $this->assertTrue($route->getMiddlewares()[0]->isRunned()); - $this->assertTrue($route->getMiddlewares()[1]->isRunned()); - $this->assertTrue($route->getMiddlewares()[2]->isRunned()); - $this->assertTrue($route->getRequestHandler()->isRunned()); - - $attributes = [ - Route::ATTR_NAME_FOR_ROUTE => $route, - Route::ATTR_NAME_FOR_ROUTE_NAME => $route->getName(), - ]; - - $this->assertSame($attributes, $route->getMiddlewares()[0]->getRequest()->getAttributes()); - $this->assertSame($attributes, $route->getMiddlewares()[1]->getRequest()->getAttributes()); - $this->assertSame($attributes, $route->getMiddlewares()[2]->getRequest()->getAttributes()); - $this->assertSame($attributes, $route->getRequestHandler()->getRequest()->getAttributes()); - } - - /** - * @return void - */ - public function testRunWithBrokenMiddleware() : void - { - $route = new Fixtures\Route(); - $route->addMiddleware(new Fixtures\Middlewares\BlankMiddleware()); - $route->addMiddleware(new Fixtures\Middlewares\BlankMiddleware(true)); - $route->addMiddleware(new Fixtures\Middlewares\BlankMiddleware()); - $route->handle((new ServerRequestFactory)->createServerRequest('GET', '/')); - - $this->assertTrue($route->getMiddlewares()[0]->isRunned()); - $this->assertTrue($route->getMiddlewares()[1]->isRunned()); - $this->assertFalse($route->getMiddlewares()[2]->isRunned()); - $this->assertFalse($route->getRequestHandler()->isRunned()); - - $attributes = [ - Route::ATTR_NAME_FOR_ROUTE => $route, - Route::ATTR_NAME_FOR_ROUTE_NAME => $route->getName(), - ]; - - $this->assertSame($attributes, $route->getMiddlewares()[0]->getRequest()->getAttributes()); - $this->assertSame($attributes, $route->getMiddlewares()[1]->getRequest()->getAttributes()); - $this->assertNull($route->getMiddlewares()[2]->getRequest()); - $this->assertNull($route->getRequestHandler()->getRequest()); - } - - /** - * @return void - */ - public function testGetClassHolder() : void - { - $class = new Fixtures\Controllers\BlankController(); - - $route = new Route('foo', '/foo', [], $class); - $holder = $route->getHolder(); - - $this->assertInstanceOf(\ReflectionClass::class, $holder); - $this->assertSame(\get_class($class), $holder->getName()); - } - - /** - * @return void - */ - public function testGetClosureHolder() : void - { - $callback = function () { - }; - - $route = new Route('foo', '/foo', [], new CallableRequestHandler($callback)); - $holder = $route->getHolder(); - - $this->assertInstanceOf(\ReflectionFunction::class, $holder); - $this->assertSame($callback, $holder->getClosure()); - } - - /** - * @return void - */ - public function testGetMethodHolder() : void - { - $class = new Fixtures\Controllers\BlankController(); - $method = '__invoke'; - - $route = new Route('foo', '/foo', [], new CallableRequestHandler([$class, $method])); - $holder = $route->getHolder(); - - $this->assertInstanceOf(\ReflectionMethod::class, $holder); - $this->assertSame(\get_class($class), $holder->getDeclaringClass()->getName()); - $this->assertSame($method, $holder->getName()); - } -} diff --git a/tests/RouterBuilderTest.php b/tests/RouterBuilderTest.php deleted file mode 100644 index 953da79d..00000000 --- a/tests/RouterBuilderTest.php +++ /dev/null @@ -1,78 +0,0 @@ -getContainer(); - $cache = $this->getCache(); - - $middlewares = []; - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - $middlewares[] = new Fixtures\Middlewares\BlankMiddleware(); - - $patterns = []; - $patterns['foo'] = 'bar'; - $patterns['bar'] = 'baz'; - - $hosts = []; - $hosts['foo'] = ['foo.net']; - $hosts['bar'] = ['bar.net']; - $hosts['baz'] = ['baz.net']; - - $builder = (new RouterBuilder) - ->setEventDispatcher($eventDispatcher) - ->setContainer($container) - ->setCache($cache) - ->setCacheKey('foo') - ->useConfigLoader([ - __DIR__ . '/Fixtures/routes/foo.php', - __DIR__ . '/Fixtures/routes/bar.php', - ]) - ->useMetadataLoader([ - Fixtures\Controllers\Annotated\MinimallyAnnotatedController::class, - Fixtures\Controllers\Annotated\MaximallyAnnotatedController::class, - ]) - ->setPatterns($patterns) - ->setHosts($hosts) - ->setMiddlewares($middlewares); - - Router::$patterns = []; - - $router = $builder->build(); - - $this->assertInstanceOf(Router::class, $router); - $this->assertSame($patterns, Router::$patterns); - $this->assertSame($hosts, $router->getHosts()); - $this->assertSame($middlewares, $router->getMiddlewares()); - $this->assertSame($eventDispatcher, $router->getEventDispatcher()); - $this->assertTrue($router->hasRoute('foo')); - $this->assertTrue($router->hasRoute('bar')); - $this->assertTrue($router->hasRoute('minimally-annotated-controller')); - $this->assertTrue($router->hasRoute('maximally-annotated-controller')); - } -} diff --git a/tests/RouterTest.php b/tests/RouterTest.php deleted file mode 100644 index 9870565c..00000000 --- a/tests/RouterTest.php +++ /dev/null @@ -1,968 +0,0 @@ -assertInstanceOf(MiddlewareInterface::class, $router); - $this->assertInstanceOf(RequestHandlerInterface::class, $router); - } - - /** - * @return void - */ - public function testAddPatterns() : void - { - $backup = Router::$patterns; - - $router = new Router(); - - try { - Router::$patterns = []; - - $router->addPatterns([ - '@foo' => 'foo', - '@bar' => 'bar', - ]); - - $this->assertSame([ - '@foo' => 'foo', - '@bar' => 'bar', - ], Router::$patterns); - - $router->addPatterns([ - '@baz' => 'baz', - ]); - - $this->assertSame([ - '@foo' => 'foo', - '@bar' => 'bar', - '@baz' => 'baz', - ], Router::$patterns); - - $router->addPatterns([ - '@bar' => 'qux', - ]); - - $this->assertSame([ - '@foo' => 'foo', - '@bar' => 'qux', - '@baz' => 'baz', - ], Router::$patterns); - } finally { - Router::$patterns = $backup; - } - } - - /** - * @return void - */ - public function testAddHosts() : void - { - $router = new Router(); - - $this->assertSame([], $router->getHosts()); - - $router->addHosts([ - 'foo' => ['foo.com', 'www.foo.com'], - 'bar' => ['bar.com', 'www.bar.com'], - ]); - - $this->assertSame([ - 'foo' => ['foo.com', 'www.foo.com'], - 'bar' => ['bar.com', 'www.bar.com'], - ], $router->getHosts()); - - $router->addHosts([ - 'baz' => ['baz.com', 'www.baz.com'], - ]); - - $this->assertSame([ - 'foo' => ['foo.com', 'www.foo.com'], - 'bar' => ['bar.com', 'www.bar.com'], - 'baz' => ['baz.com', 'www.baz.com'], - ], $router->getHosts()); - - $router->addHosts([ - 'bar' => ['qux.com', 'www.qux.com'], - ]); - - $this->assertSame([ - 'foo' => ['foo.com', 'www.foo.com'], - 'bar' => ['qux.com', 'www.qux.com'], - 'baz' => ['baz.com', 'www.baz.com'], - ], $router->getHosts()); - } - - /** - * @return void - */ - public function testAddHost() : void - { - $router = new Router(); - - $this->assertSame([], $router->getHosts()); - - $router->addHost('google', 'google.com', 'www.google.com'); - - $this->assertSame([ - 'google' => ['google.com', 'www.google.com'], - ], $router->getHosts()); - - $router->addHost('yahoo', 'yahoo.com'); - - $this->assertSame([ - 'google' => ['google.com', 'www.google.com'], - 'yahoo' => ['yahoo.com'], - ], $router->getHosts()); - - $router->addHost('google', 'localhost'); - - $this->assertSame([ - 'google' => ['localhost'], - 'yahoo' => ['yahoo.com'], - ], $router->getHosts()); - } - - /** - * @return void - */ - public function testAddRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $this->assertSame($routes, $router->getRoutes()); - } - - /** - * @return void - */ - public function testAddExistingRoute() : void - { - $route = new Fixtures\Route(); - - $router = new Router(); - $router->addRoute($route); - - $this->expectException(InvalidArgumentException::class); - - try { - $router->addRoute($route); - } catch (InvalidArgumentException $e) { - throw $e; - } - } - - /** - * @return void - */ - public function testAddMiddleware() : void - { - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $router = new Router(); - $router->addMiddleware(...$middlewares); - - $this->assertSame($middlewares, $router->getMiddlewares()); - } - - /** - * @return void - */ - public function testAddExistingMiddleware() : void - { - $middleware = new Fixtures\Middlewares\BlankMiddleware(); - - $router = new Router(); - $router->addMiddleware($middleware); - - $this->expectException(InvalidArgumentException::class); - - try { - $router->addMiddleware($middleware); - } catch (InvalidArgumentException $e) { - throw $e; - } - } - - /** - * @return void - */ - public function testGetAllowedMethods() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $expected = array_unique(array_merge( - $routes[0]->getMethods(), - $routes[1]->getMethods(), - $routes[2]->getMethods() - )); - - $router = new Router(); - - $this->assertSame([], $router->getAllowedMethods()); - - $router->addRoute(...$routes); - - $this->assertSame($expected, $router->getAllowedMethods()); - } - - /** - * @return void - */ - public function testGetRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $this->assertSame($routes[1], $router->getRoute($routes[1]->getName())); - } - - /** - * @return void - */ - public function testGetUndefinedRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $this->expectException(RouteNotFoundException::class); - - try { - $router->getRoute('foo'); - } catch (RouteNotFoundException $e) { - throw $e; - } - } - - /** - * The test method only proxies the function path_build, - * the function should be tested separately. - * - * @return void - */ - public function testGenerateUri() : void - { - $route = new Fixtures\Route(); - - $router = new Router(); - $router->addRoute($route); - - $this->assertSame($route->getPath(), $router->generateUri($route->getName())); - } - - /** - * @return void - */ - public function testMatch() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $foundRoute = $router->match((new ServerRequestFactory) - ->createServerRequest( - $routes[2]->getMethods()[1], - $routes[2]->getPath() - )); - - $this->assertSame($routes[2]->getName(), $foundRoute->getName()); - } - - /** - * @return void - */ - public function testMatchWithUnallowedMethod() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $routes[1]->setPath('/foo'); - $routes[2]->setPath('/foo'); - $routes[3]->setPath('/foo'); - - $router = new Router(); - $router->addRoute(...$routes); - - $request = (new ServerRequestFactory) - ->createServerRequest('GET', '/foo'); - - $this->expectException(MethodNotAllowedException::class); - - try { - $router->match($request); - } catch (MethodNotAllowedException $e) { - $expected = array_unique(array_merge( - $routes[1]->getMethods(), - $routes[2]->getMethods(), - $routes[3]->getMethods() - )); - - $this->assertSame($request->getMethod(), $e->fromContext('method')); - $this->assertSame($request->getMethod(), $e->getMethod()); - - $this->assertSame($expected, $e->fromContext('allowed')); - $this->assertSame($expected, $e->getAllowedMethods()); - - throw $e; - } - } - - /** - * @return void - */ - public function testMatchWithUndefinedRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $request = (new ServerRequestFactory) - ->createServerRequest($routes[0]->getMethods()[0], '/'); - - $this->expectException(PageNotFoundException::class); - - try { - $router->match($request); - } catch (PageNotFoundException $e) { - throw $e; - } - } - - /** - * @return void - */ - public function testRun() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $router->run((new ServerRequestFactory) - ->createServerRequest( - $routes[1]->getMethods()[1], - $routes[1]->getPath() - )); - - $this->assertNotNull($router->getMatchedRoute()); - $this->assertSame($routes[1]->getName(), $router->getMatchedRoute()->getName()); - $this->assertTrue($routes[1]->getRequestHandler()->isRunned()); - } - - /** - * @return void - */ - public function testRunWithUnallowedMethod() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $router->addMiddleware(new CallableMiddleware(function ($request, $handler) { - try { - return $handler->handle($request); - } catch (MethodNotAllowedException $e) { - return (new ResponseFactory)->createResponse(405); - } - })); - - $response = $router->run((new ServerRequestFactory) - ->createServerRequest('UNALLOWED', $routes[1]->getPath())); - - $this->assertSame(405, $response->getStatusCode()); - } - - /** - * @return void - */ - public function testRunWithUndefinedRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $router->addMiddleware(new CallableMiddleware(function ($request, $handler) { - try { - return $handler->handle($request); - } catch (RouteNotFoundException $e) { - return (new ResponseFactory)->createResponse(404); - } - })); - - $response = $router->run((new ServerRequestFactory) - ->createServerRequest($routes[1]->getMethods()[1], '/undefined')); - - $this->assertSame(404, $response->getStatusCode()); - } - - /** - * @return void - */ - public function testHandle() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $router->handle((new ServerRequestFactory) - ->createServerRequest( - $routes[2]->getMethods()[1], - $routes[2]->getPath() - )); - - $this->assertNotNull($router->getMatchedRoute()); - $this->assertSame($routes[2]->getName(), $router->getMatchedRoute()->getName()); - $this->assertTrue($routes[2]->getRequestHandler()->isRunned()); - } - - /** - * @return void - */ - public function testHandleWithMiddlewares() : void - { - $route = new Fixtures\Route(); - - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $router = new Router(); - $router->addRoute($route); - $router->addMiddleware(...$middlewares); - - $router->handle((new ServerRequestFactory) - ->createServerRequest( - $route->getMethods()[0], - $route->getPath() - )); - - $this->assertTrue($middlewares[0]->isRunned()); - $this->assertTrue($middlewares[1]->isRunned()); - $this->assertTrue($middlewares[2]->isRunned()); - $this->assertTrue($route->getRequestHandler()->isRunned()); - } - - /** - * @return void - */ - public function testHandleWithBrokenMiddleware() : void - { - $route = new Fixtures\Route(); - - $middlewares = [ - new Fixtures\Middlewares\BlankMiddleware(), - new Fixtures\Middlewares\BlankMiddleware(true), - new Fixtures\Middlewares\BlankMiddleware(), - ]; - - $router = new Router(); - $router->addRoute($route); - $router->addMiddleware(...$middlewares); - - $router->handle((new ServerRequestFactory) - ->createServerRequest( - $route->getMethods()[0], - $route->getPath() - )); - - $this->assertTrue($middlewares[0]->isRunned()); - $this->assertTrue($middlewares[1]->isRunned()); - $this->assertFalse($middlewares[2]->isRunned()); - $this->assertFalse($route->getRequestHandler()->isRunned()); - } - - /** - * @return void - */ - public function testHandleWithUnallowedMethod() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $request = (new ServerRequestFactory) - ->createServerRequest('GET', $routes[1]->getPath()); - - $this->expectException(MethodNotAllowedException::class); - - try { - $router->handle($request); - } catch (MethodNotAllowedException $e) { - $allowedMethods = $routes[1]->getMethods(); - - // $this->assertSame('GET', $e->fromContext('method')); - $this->assertSame($allowedMethods, $e->fromContext('allowed')); - $this->assertSame($allowedMethods, $e->getAllowedMethods()); - - throw $e; - } - } - - /** - * @return void - */ - public function testHandleWithUndefinedRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $request = (new ServerRequestFactory) - ->createServerRequest($routes[0]->getMethods()[0], '/'); - - $this->expectException(RouteNotFoundException::class); - - try { - $router->handle($request); - } catch (RouteNotFoundException $e) { - throw $e; - } - } - - /** - * @return void - */ - public function testProcess() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $fallback = new Fixtures\Controllers\BlankController(); - - $router->process((new ServerRequestFactory) - ->createServerRequest( - $routes[2]->getMethods()[1], - $routes[2]->getPath() - ), $fallback); - - $this->assertTrue($routes[2]->getRequestHandler()->isRunned()); - $this->assertFalse($fallback->isRunned()); - } - - /** - * @return void - */ - public function testProcessWithUnallowedMethod() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $request = (new ServerRequestFactory) - ->createServerRequest('GET', $routes[0]->getPath()); - - $fallback = new Fixtures\Controllers\BlankController(); - - $router->process($request, $fallback); - - $this->assertInstanceOf( - MethodNotAllowedException::class, - $fallback->getRequest()->getAttribute(Router::ATTR_NAME_FOR_ROUTING_ERROR) - ); - - $this->assertTrue($fallback->isRunned()); - } - - /** - * @return void - */ - public function testProcessWithUndefinedRoute() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $router = new Router(); - $router->addRoute(...$routes); - - $request = (new ServerRequestFactory) - ->createServerRequest($routes[0]->getMethods()[0], '/'); - - $fallback = new Fixtures\Controllers\BlankController(); - - $router->process($request, $fallback); - - $this->assertInstanceOf( - RouteNotFoundException::class, - $fallback->getRequest()->getAttribute(Router::ATTR_NAME_FOR_ROUTING_ERROR) - ); - - $this->assertTrue($fallback->isRunned()); - } - - /** - * @return void - */ - public function testLoad() : void - { - $router = new Router(); - - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $loader = $this->createMock(LoaderInterface::class); - $loader->method('load') - ->willReturn(new RouteCollection(...$routes)); - - $router->load($loader); - - $this->assertSame($routes, $router->getRoutes()); - } - - /** - * @return void - */ - public function testMultipleLoad() : void - { - $router = new Router(); - - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $loaders = []; - - $loaders[0] = $this->createMock(LoaderInterface::class); - $loaders[0]->method('load') - ->willReturn(new RouteCollection($routes[0])); - - $loaders[1] = $this->createMock(LoaderInterface::class); - $loaders[1]->method('load') - ->willReturn(new RouteCollection($routes[1])); - - $loaders[2] = $this->createMock(LoaderInterface::class); - $loaders[2]->method('load') - ->willReturn(new RouteCollection($routes[2])); - - $router->load(...$loaders); - - $this->assertSame($routes, $router->getRoutes()); - } - - /** - * @return void - */ - public function testMatchWithHosts() : void - { - $requestHandler = new Fixtures\Controllers\BlankController(); - - $routes = [ - new Route('foo', '/ping', ['GET'], $requestHandler), - new Route('bar', '/ping', ['GET'], $requestHandler), - new Route('baz', '/ping', ['GET'], $requestHandler), - new Route('qux', '/ping', ['GET'], $requestHandler), - ]; - - $routes[0]->setHost('foo'); - $routes[1]->setHost('bar'); - $routes[2]->setHost('baz'); - - $router = new Router(); - $router->addHost('foo', 'foo.net'); - $router->addHost('bar', 'bar.net'); - $router->addHost('baz', 'baz.net'); - $router->addRoute(...$routes); - - // hosted route - $foundRoute = $router->match((new ServerRequestFactory) - ->createServerRequest('GET', 'http://foo.net/ping')); - $this->assertSame($routes[0]->getName(), $foundRoute->getName()); - - // hosted route - $foundRoute = $router->match((new ServerRequestFactory) - ->createServerRequest('GET', 'http://bar.net/ping')); - $this->assertSame($routes[1]->getName(), $foundRoute->getName()); - - // hosted route - $foundRoute = $router->match((new ServerRequestFactory) - ->createServerRequest('GET', 'http://baz.net/ping')); - $this->assertSame($routes[2]->getName(), $foundRoute->getName()); - - // non-hosted route - $foundRoute = $router->match((new ServerRequestFactory) - ->createServerRequest('GET', 'http://localhost/ping')); - $this->assertSame($routes[3]->getName(), $foundRoute->getName()); - } - - /** - * @return void - */ - public function testEventDispatcher() : void - { - $router = new Router(); - $this->assertNull($router->getEventDispatcher()); - - $eventDispatcher = new EventDispatcher(); - $router->setEventDispatcher($eventDispatcher); - $this->assertSame($eventDispatcher, $router->getEventDispatcher()); - - $router->setEventDispatcher(null); - $this->assertNull($router->getEventDispatcher()); - } - - /** - * @return void - */ - public function testRouteEvent() : void - { - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $request = (new ServerRequestFactory) - ->createServerRequest( - $routes[1]->getMethods()[1], - $routes[1]->getPath() - ); - - $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(RouteEvent::NAME, function (RouteEvent $event) use ($routes, $request) { - $this->assertSame($routes[1]->getName(), $event->getRoute()->getName()); - $this->assertSame($request, $event->getRequest()); - }); - - $router = new Router(); - $router->addRoute(...$routes); - $router->setEventDispatcher($eventDispatcher); - $router->run($request); - } - - /** - * @return void - */ - public function testRouteEventOverrideRequest() : void - { - $route = new Fixtures\Route(); - - $request = (new ServerRequestFactory) - ->createServerRequest( - $route->getMethods()[0], - $route->getPath() - ); - - $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(RouteEvent::NAME, function (RouteEvent $event) use ($request) { - $event->setRequest($request->withAttribute('foo', 'bar')); - $this->assertNotSame($request, $event->getRequest()); - }); - - $router = new Router(); - $router->addRoute($route); - $router->setEventDispatcher($eventDispatcher); - $router->handle($request); - } - - /** - * @return void - */ - public function testResolveHost() : void - { - $router = new Router(); - $router->addHost('foo', 'www1.foo.com', 'www2.foo.com'); - $router->addHost('bar', 'www1.bar.com', 'www2.bar.com'); - - $this->assertSame('foo', $router->resolveHostname('www1.foo.com')); - $this->assertSame('foo', $router->resolveHostname('www2.foo.com')); - $this->assertSame('bar', $router->resolveHostname('www1.bar.com')); - $this->assertSame('bar', $router->resolveHostname('www2.bar.com')); - $this->assertNull($router->resolveHostname('example.com')); - } - - /** - * @return void - */ - public function testGetRoutesByHostname() : void - { - $router = new Router(); - $router->addHost('foo', 'www1.foo.com', 'www2.foo.com'); - $router->addHost('bar', 'www1.bar.com', 'www2.bar.com'); - - $routes = [ - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - new Fixtures\Route(), - ]; - - $routes[0]->setHost('foo'); - $routes[2]->setHost('bar'); - $routes[4]->setHost('bar'); - - $router->addRoute(...$routes); - - $this->assertSame([ - $routes[0], - $routes[1], - $routes[3], - $routes[5], - ], $router->getRoutesByHostname('www1.foo.com')); - - $this->assertSame([ - $routes[0], - $routes[1], - $routes[3], - $routes[5], - ], $router->getRoutesByHostname('www2.foo.com')); - - $this->assertSame([ - $routes[1], - $routes[2], - $routes[3], - $routes[4], - $routes[5], - ], $router->getRoutesByHostname('www1.bar.com')); - - $this->assertSame([ - $routes[1], - $routes[2], - $routes[3], - $routes[4], - $routes[5], - ], $router->getRoutesByHostname('www2.bar.com')); - - $this->assertSame([ - $routes[1], - $routes[3], - $routes[5], - ], $router->getRoutesByHostname('localhost')); - } -}