diff --git a/composer.json b/composer.json index def19cb..627107e 100644 --- a/composer.json +++ b/composer.json @@ -40,8 +40,7 @@ }, "autoload-dev": { "psr-4": { - "GT\\Routing\\Test\\": "./test/phpunit", - "Gt\\Routing\\Test\\": "./test/phpunit" + "GT\\Routing\\Test\\": "./test/phpunit" } }, "config": { diff --git a/composer.lock b/composer.lock index 23c678b..0c8a8b5 100644 --- a/composer.lock +++ b/composer.lock @@ -59,16 +59,16 @@ }, { "name": "phpgt/async", - "version": "v1.0.1", + "version": "v1.0.3", "source": { "type": "git", "url": "https://github.com/phpgt/Async.git", - "reference": "3daab887eacd04d297a1383a22957f0ab7ecaf68" + "reference": "d1f3679f4200e32457b57211f49b999641ef8fa4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpgt/Async/zipball/3daab887eacd04d297a1383a22957f0ab7ecaf68", - "reference": "3daab887eacd04d297a1383a22957f0ab7ecaf68", + "url": "https://api.github.com/repos/phpgt/Async/zipball/d1f3679f4200e32457b57211f49b999641ef8fa4", + "reference": "d1f3679f4200e32457b57211f49b999641ef8fa4", "shasum": "" }, "require": { @@ -85,6 +85,7 @@ "type": "library", "autoload": { "psr-4": { + "GT\\Async\\": "./src", "Gt\\Async\\": "./src" } }, @@ -95,15 +96,15 @@ "description": "Promise-based non-blocking operations.", "support": { "issues": "https://github.com/phpgt/Async/issues", - "source": "https://github.com/phpgt/Async/tree/v1.0.1" + "source": "https://github.com/phpgt/Async/tree/v1.0.3" }, "funding": [ { - "url": "https://github.com/phpgt", + "url": "https://github.com/sponsors/PhpGt", "type": "github" } ], - "time": "2025-03-07T17:26:32+00:00" + "time": "2026-05-04T16:34:25+00:00" }, { "name": "phpgt/config", @@ -223,16 +224,16 @@ }, { "name": "phpgt/dataobject", - "version": "v1.1.0", + "version": "v1.1.1", "source": { "type": "git", - "url": "https://github.com/PhpGt/DataObject.git", - "reference": "534b593617e40f62ba5ea5a399797e4b0107a40a" + "url": "https://github.com/phpgt/DataObject.git", + "reference": "fc1918f2c1a9f3329d8d80bd01f3658cf07961ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/DataObject/zipball/534b593617e40f62ba5ea5a399797e4b0107a40a", - "reference": "534b593617e40f62ba5ea5a399797e4b0107a40a", + "url": "https://api.github.com/repos/phpgt/DataObject/zipball/fc1918f2c1a9f3329d8d80bd01f3658cf07961ba", + "reference": "fc1918f2c1a9f3329d8d80bd01f3658cf07961ba", "shasum": "" }, "require": { @@ -242,13 +243,14 @@ }, "require-dev": { "phpmd/phpmd": "^2.13", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.1", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^4.0" }, "type": "library", "autoload": { "psr-4": { + "GT\\DataObject\\": "./src", "Gt\\DataObject\\": "./src" } }, @@ -264,16 +266,16 @@ ], "description": " Structured, type-safe, immutable data transfer.", "support": { - "issues": "https://github.com/PhpGt/DataObject/issues", - "source": "https://github.com/PhpGt/DataObject/tree/v1.1.0" + "issues": "https://github.com/phpgt/DataObject/issues", + "source": "https://github.com/phpgt/DataObject/tree/v1.1.1" }, "funding": [ { - "url": "https://github.com/phpgt", + "url": "https://github.com/sponsors/phpgt", "type": "github" } ], - "time": "2025-02-14T16:43:15+00:00" + "time": "2026-04-21T12:24:50+00:00" }, { "name": "phpgt/http", @@ -1024,20 +1026,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -1076,9 +1077,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "pdepend/pdepend", @@ -1720,24 +1721,24 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.63", + "version": "10.5.64", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "33198268dad71e926626b618f3ec3966661e4d90" + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", - "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -1801,31 +1802,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T05:48:37+00:00" + "time": "2026-07-06T14:50:35+00:00" }, { "name": "psr/log", @@ -2911,16 +2896,16 @@ }, { "name": "symfony/config", - "version": "v6.4.34", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9" + "reference": "922d980c90a69ffdd63e6ee551efb338b58be677" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/ce9cb0c0d281aaf188b802d4968e42bfb60701e9", - "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9", + "url": "https://api.github.com/repos/symfony/config/zipball/922d980c90a69ffdd63e6ee551efb338b58be677", + "reference": "922d980c90a69ffdd63e6ee551efb338b58be677", "shasum": "" }, "require": { @@ -2966,7 +2951,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.34" + "source": "https://github.com/symfony/config/tree/v6.4.42" }, "funding": [ { @@ -2986,20 +2971,20 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:34:50+00:00" + "time": "2026-06-09T07:36:15+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.36", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "cd7881a6dc84b780411199cd0584e1a53a3b9ba7" + "reference": "ba3538ae8d14dd9a3a8758229e8ddaeff1a23643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/cd7881a6dc84b780411199cd0584e1a53a3b9ba7", - "reference": "cd7881a6dc84b780411199cd0584e1a53a3b9ba7", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/ba3538ae8d14dd9a3a8758229e8ddaeff1a23643", + "reference": "ba3538ae8d14dd9a3a8758229e8ddaeff1a23643", "shasum": "" }, "require": { @@ -3051,7 +3036,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.36" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.42" }, "funding": [ { @@ -3071,20 +3056,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T16:39:36+00:00" + "time": "2026-06-08T07:06:12+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3097,7 +3082,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3122,7 +3107,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3133,25 +3118,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.34", + "version": "v6.4.39", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", "shasum": "" }, "require": { @@ -3188,7 +3177,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.34" + "source": "https://github.com/symfony/filesystem/tree/v6.4.39" }, "funding": [ { @@ -3208,20 +3197,20 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:51:06+00:00" + "time": "2026-05-07T13:11:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -3271,7 +3260,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -3291,20 +3280,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -3356,7 +3345,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -3376,20 +3365,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -3407,7 +3396,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3443,7 +3432,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -3463,20 +3452,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.36", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "f9c4a9695a9e2bbc65c920e147d8d7ae28f8d79a" + "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/f9c4a9695a9e2bbc65c920e147d8d7ae28f8d79a", - "reference": "f9c4a9695a9e2bbc65c920e147d8d7ae28f8d79a", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", + "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", "shasum": "" }, "require": { @@ -3524,7 +3513,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.36" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.42" }, "funding": [ { @@ -3544,7 +3533,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T15:06:19+00:00" + "time": "2026-06-27T08:12:01+00:00" }, { "name": "theseer/tokenizer", diff --git a/src/BaseRouter.php b/src/BaseRouter.php index 83a4ac1..13009e2 100644 --- a/src/BaseRouter.php +++ b/src/BaseRouter.php @@ -5,7 +5,6 @@ use Gt\Http\ResponseStatusException\ClientError\HttpNotAcceptable; use Gt\ServiceContainer\Container; use Gt\ServiceContainer\Injector; -use Negotiation\Accept; use Psr\Http\Message\RequestInterface; use Gt\Http\ResponseStatusException\Redirection\HttpFound; use Gt\Http\ResponseStatusException\Redirection\HttpMovedPermanently; @@ -103,11 +102,9 @@ private function findRedirectTarget(Redirects $redirects, string $uri):?string { } public function route(RequestInterface $request):void { - /** @var array $validCallbackArray */ - $validCallbackArray = []; - $acceptHeader = $this->getEffectiveAcceptHeader( - $request->getHeaderLine("accept") - ); + $acceptHeader = $request->getHeaderLine("accept"); + /** @var array $routeMatchingCallbackArray */ + $routeMatchingCallbackArray = []; // Find all callbacks that match the current request, filling the valid callback // array. Then, the "best" callback will be matched using content negotiation. foreach($this->reflectRouterCallbacks() as $routerCallback) { @@ -117,16 +114,13 @@ public function route(RequestInterface $request):void { if(!$routerCallback->matchesPath($request->getUri()->getPath())) { continue; } - if(!$routerCallback->matchesAccept($acceptHeader)) { - continue; - } - - array_push($validCallbackArray, $routerCallback); + array_push($routeMatchingCallbackArray, $routerCallback); } - $bestRouterCallback = $this->negotiateBestCallback( + $contentTypeNegotiator = new ContentTypeNegotiator($this->config); + $bestRouterCallback = $contentTypeNegotiator->negotiate( $acceptHeader, - $validCallbackArray + $routeMatchingCallbackArray ); if(!$bestRouterCallback) { @@ -225,52 +219,4 @@ private function reflectRouterCallbacks():array { return $routerCallbackArray; } - /** @param array $callbackArray */ - private function negotiateBestCallback( - string $acceptHeader, - array $callbackArray - ):?RouterCallback { - $bestQuality = -1; - $bestCallback = null; - foreach($callbackArray as $callback) { - /** @var Accept|null $best */ - $best = $callback->getBestNegotiation( - $acceptHeader - ); - - $quality = $best?->getQuality() ?? 0; - if($quality <= $bestQuality) { - continue; - } - - $bestQuality = $quality; - $bestCallback = $callback; - } - - return $bestCallback; - } - - private function getEffectiveAcceptHeader(string $acceptHeader):string { - if(!$this->isWildcardOnlyAcceptHeader($acceptHeader)) { - return $acceptHeader; - } - - return $this->config?->defaultContentType ?? $acceptHeader; - } - - private function isWildcardOnlyAcceptHeader(string $acceptHeader):bool { - $acceptHeader = trim($acceptHeader); - if($acceptHeader === "") { - return true; - } - - foreach(explode(",", $acceptHeader) as $acceptPart) { - $mediaType = trim(explode(";", $acceptPart, 2)[0]); - if($mediaType !== "*/*") { - return false; - } - } - - return true; - } } diff --git a/src/ContentTypeNegotiator.php b/src/ContentTypeNegotiator.php new file mode 100644 index 0000000..e7541c1 --- /dev/null +++ b/src/ContentTypeNegotiator.php @@ -0,0 +1,141 @@ + $callbackArray + */ + public function negotiate( + string $acceptHeader, + array $callbackArray + ):?RouterCallback { + if($this->isWildcardOnlyAcceptHeader($acceptHeader)) { + return $this->negotiateBestCallback( + $this->getDefaultContentType($acceptHeader), + $callbackArray + )[0] ?? null; + } + + $bestNegotiation = $this->negotiateBestCallback( + $acceptHeader, + $callbackArray + ); + + if($this->isLooseAcceptHeader($acceptHeader)) { + $defaultContentType = $this->negotiateDefaultContentType( + $acceptHeader + ); + if($defaultContentType + && $defaultContentType->getQuality() >= ($bestNegotiation[1]?->getQuality() ?? 0)) { + $defaultNegotiation = $this->negotiateBestCallback( + $defaultContentType->getType(), + $callbackArray + ); + if($defaultNegotiation) { + return $defaultNegotiation[0]; + } + } + } + + if($bestNegotiation) { + return $bestNegotiation[0]; + } + + return null; + } + + /** + * @param array $callbackArray + * @return null|array{RouterCallback, ?Accept} + */ + private function negotiateBestCallback( + string $acceptHeader, + array $callbackArray + ):?array { + $bestQuality = -1; + $bestCallback = null; + $bestNegotiation = null; + foreach($callbackArray as $callback) { + if(!$callback->matchesAccept($acceptHeader)) { + continue; + } + + $best = $callback->getBestNegotiation($acceptHeader); + $quality = $best?->getQuality() ?? 0; + if($quality <= $bestQuality) { + continue; + } + + $bestQuality = $quality; + $bestCallback = $callback; + $bestNegotiation = $best; + } + + if(!$bestCallback) { + return null; + } + + return [$bestCallback, $bestNegotiation]; + } + + private function getDefaultContentType(string $acceptHeader):string { + return $this->config?->defaultContentType ?? $acceptHeader; + } + + private function negotiateDefaultContentType( + string $acceptHeader + ):?BaseAccept { + $defaultContentType = $this->config?->defaultContentType; + if(!$defaultContentType) { + return null; + } + + $negotiator = new Negotiator(); + $best = $negotiator->getBest( + $acceptHeader, + explode(",", $defaultContentType) + ); + if(!$best instanceof BaseAccept) { + return null; + } + + return $best; + } + + private function isLooseAcceptHeader(string $acceptHeader):bool { + if($this->isWildcardOnlyAcceptHeader($acceptHeader)) { + return false; + } + + foreach(explode(",", $acceptHeader) as $acceptPart) { + $mediaType = trim(explode(";", $acceptPart, 2)[0]); + if(str_contains($mediaType, "*")) { + return true; + } + } + + return false; + } + + private function isWildcardOnlyAcceptHeader(string $acceptHeader):bool { + $acceptHeader = trim($acceptHeader); + if($acceptHeader === "") { + return true; + } + + foreach(explode(",", $acceptHeader) as $acceptPart) { + $mediaType = trim(explode(";", $acceptPart, 2)[0]); + if($mediaType !== "*/*") { + return false; + } + } + + return true; + } +} diff --git a/test/phpunit/BaseRouterTest.php b/test/phpunit/BaseRouterTest.php index 687ce52..2e6676a 100644 --- a/test/phpunit/BaseRouterTest.php +++ b/test/phpunit/BaseRouterTest.php @@ -342,6 +342,60 @@ public function thisShouldMatch():void { $sut->route($request); } + public function testRoute_failsWhenAcceptHasNoMatch():void { + $uri = self::createMock(Uri::class); + $uri->method("getPath")->willReturn("/something"); + $request = self::createMock(Request::class); + $request->method("getUri")->willReturn($uri); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("application/yaml"); + + $sut = new class(new RouterConfig(307, "text/html")) extends BaseRouter { + /** @noinspection PhpUnused */ + #[Any(path: "/something", accept: "text/html")] + public function thisShouldNotMatch():void { + throw new Exception("Route 1"); + } + + /** @noinspection PhpUnused */ + #[Any(path: "/something", accept: "application/json")] + public function thisShouldMatch():void { + throw new Exception("Route 2"); + } + }; + self::expectException(HttpNotAcceptable::class); + $sut->route($request); + } + + public function testRoute_usesDefaultContentTypeForLooseAccept():void { + $uri = self::createMock(Uri::class); + $uri->method("getPath")->willReturn("/something"); + $request = self::createMock(Request::class); + $request->method("getUri")->willReturn($uri); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("text/*"); + + $sut = new class(new RouterConfig(307, "text/plain,text/html")) extends BaseRouter { + /** @noinspection PhpUnused */ + #[Any(path: "/something", accept: "text/html")] + public function thisShouldNotMatch():void { + throw new Exception("Route 1"); + } + + /** @noinspection PhpUnused */ + #[Any(path: "/something", accept: "text/plain")] + public function thisShouldMatch():void { + throw new Exception("Route 2"); + } + }; + self::expectExceptionMessage("Route 2"); + $sut->route($request); + } + /** @dataProvider data_redirectCode */ public function testHandleRedirects_responseCodeFromConfig( int $code,