diff options
| -rw-r--r-- | composer.json | 10 | ||||
| -rw-r--r-- | composer.lock | 427 | ||||
| -rw-r--r-- | phpstan.neon | 5 | ||||
| -rw-r--r-- | src/Enums/PathSegmentType.php (renamed from src/Enums/SpecialSegment.php) | 6 | ||||
| -rw-r--r-- | src/Exceptions/NotFoundException.php | 12 | ||||
| -rw-r--r-- | src/Exceptions/UnknownMethodException.php | 12 | ||||
| -rw-r--r-- | src/PathSegment.php | 123 | ||||
| -rw-r--r-- | src/PathSegmentMatch.php | 15 | ||||
| -rw-r--r-- | src/Route.php | 34 | ||||
| -rw-r--r-- | src/Router.php | 71 | ||||
| -rw-r--r-- | tests/Unit/PathSegmentTest.php | 215 | ||||
| -rw-r--r-- | tests/Unit/RouteTest.php | 48 | ||||
| -rw-r--r-- | tests/Unit/RouterTest.php | 90 |
13 files changed, 942 insertions, 126 deletions
diff --git a/composer.json b/composer.json index bf0d70b..6ecde7c 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,9 @@ "type": "library", "autoload": { "psr-4": { - "Lightscale\\Router\\": "src/" + "Lightscale\\Router\\": "src/", + "Lightscale\\Router\\Test\\": "tests/" + } }, "authors": [ @@ -14,13 +16,15 @@ } ], "require": { - "php": ">=8.3" + "php": ">=8.3", + "psr/http-message": "^2.0" }, "require-dev": { "pestphp/pest": "^4.7", "phpstan/phpstan": "^2.2", "friendsofphp/php-cs-fixer": "^3.95", - "ace-of-aces/intellipest": "^1.0" + "ace-of-aces/intellipest": "^1.0", + "nyholm/psr7": "^1.8" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index e5defa7..707d8d0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,62 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1af391e5424ed257200dd8dd28529f5a", - "packages": [], + "content-hash": "1b580d80d8e453e316c8a11cd22f4157", + "packages": [ + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + } + ], "packages-dev": [ { "name": "ace-of-aces/intellipest", @@ -734,23 +788,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.2", + "version": "v3.95.3", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" + "reference": "3d681493acc0e93283481b1c63c263737df78687" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", - "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3d681493acc0e93283481b1c63c263737df78687", + "reference": "3d681493acc0e93283481b1c63c263737df78687", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", - "ergebnis/agent-detector": "^1.1.1", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -767,10 +821,10 @@ "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/polyfill-mbstring": "^1.33", - "symfony/polyfill-php80": "^1.33", - "symfony/polyfill-php81": "^1.33", - "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, @@ -784,9 +838,9 @@ "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php85": "^1.37", "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -827,7 +881,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.3" }, "funding": [ { @@ -835,7 +889,7 @@ "type": "github" } ], - "time": "2026-05-15T09:20:44+00:00" + "time": "2026-05-29T20:35:26+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1199,6 +1253,84 @@ "time": "2026-02-16T23:10:27+00:00" }, { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { "name": "pestphp/pest", "version": "v4.7.0", "source": { @@ -2533,6 +2665,61 @@ "time": "2019-01-08T18:20:26+00:00" }, { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { "name": "psr/log", "version": "3.0.2", "source": { @@ -4122,23 +4309,29 @@ }, { "name": "symfony/console", - "version": "v8.0.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f200be111431cff4aeae8d086b91180bc4d7efd7" + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f200be111431cff4aeae8d086b91180bc4d7efd7", - "reference": "f200be111431cff4aeae8d086b91180bc4d7efd7", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4|^8.0" + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" @@ -4146,14 +4339,18 @@ "require-dev": { "psr/log": "^1|^2|^3", "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", "symfony/lock": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", @@ -4188,7 +4385,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.13" + "source": "https://github.com/symfony/console/tree/v8.1.0" }, "funding": [ { @@ -4208,7 +4405,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:59:15+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4283,20 +4480,21 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -4344,7 +4542,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -4364,7 +4562,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4448,20 +4646,21 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, @@ -4494,7 +4693,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.11" + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" }, "funding": [ { @@ -4514,24 +4713,24 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:39:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/finder", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8da41214757b87d97f181e3d14a4179286151007" + "reference": "58d2e767a66052c1487356f953445634a8194c64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", - "reference": "8da41214757b87d97f181e3d14a4179286151007", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "require-dev": { "symfony/filesystem": "^7.4|^8.0" @@ -4562,7 +4761,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.8" + "source": "https://github.com/symfony/finder/tree/v8.1.0" }, "funding": [ { @@ -4582,24 +4781,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/options-resolver", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -4633,7 +4832,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { @@ -4653,7 +4852,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5235,21 +5434,101 @@ "time": "2026-05-26T12:51:13+00:00" }, { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-26T02:25:22+00:00" + }, + { "name": "symfony/process", - "version": "v8.0.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4" + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", - "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -5277,7 +5556,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.13" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -5297,7 +5576,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:05:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", @@ -5388,20 +5667,20 @@ }, { "name": "symfony/stopwatch", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -5430,7 +5709,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -5450,24 +5729,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/string", - "version": "v8.0.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc", - "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -5520,7 +5799,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.13" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -5540,7 +5819,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:05:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/phpstan.neon b/phpstan.neon index 2f87fc7..6e02a9e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,3 +8,8 @@ parameters: message: '#Call to an undefined method Pest\\PendingCalls\\TestCall::[a-zA-Z0-9\\_]+#' path: tests reportUnmatched: false + - + message: '#Access to an undefined property Pest\\(Mixins\\)?Expectation#' + identifier: property.notFound + path: tests + reportUnmatched: false diff --git a/src/Enums/SpecialSegment.php b/src/Enums/PathSegmentType.php index 6b451c5..6547842 100644 --- a/src/Enums/SpecialSegment.php +++ b/src/Enums/PathSegmentType.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Lightscale\Router\Enums; -enum SpecialSegment: string +enum PathSegmentType { - case Root = '<root>'; + case Raw; + case Root; + case Parameter; } diff --git a/src/Exceptions/NotFoundException.php b/src/Exceptions/NotFoundException.php new file mode 100644 index 0000000..306ba15 --- /dev/null +++ b/src/Exceptions/NotFoundException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Lightscale\Router\Exceptions; + +use RuntimeException; + +class NotFoundException extends RuntimeException +{ + protected $message = 'Could not find request route'; +} diff --git a/src/Exceptions/UnknownMethodException.php b/src/Exceptions/UnknownMethodException.php new file mode 100644 index 0000000..220cf42 --- /dev/null +++ b/src/Exceptions/UnknownMethodException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Lightscale\Router\Exceptions; + +use RuntimeException; + +class UnknownMethodException extends RuntimeException +{ + protected $message = 'Unknown HTTP request method'; +} diff --git a/src/PathSegment.php b/src/PathSegment.php index 7f24660..5333d17 100644 --- a/src/PathSegment.php +++ b/src/PathSegment.php @@ -5,37 +5,66 @@ declare(strict_types=1); namespace Lightscale\Router; use Lightscale\Router\Enums\HttpMethod; -use Lightscale\Router\Enums\SpecialSegment; +use Lightscale\Router\Enums\PathSegmentType; class PathSegment { private const MAX_ANCESTORY_DEPTH = 100; - protected string $content; - - protected ?SpecialSegment $specialSegment = null; - /** @var array<string, self> */ protected array $children; - /** @var array<value-of<HttpMethod>, Route[]> */ + /** @var array<value-of<HttpMethod>, Route> */ protected array $routes = []; + protected ?self $parent = null; + public function __construct( - SpecialSegment|string $content, - protected ?self $parent = null, + protected ?string $value = null, + protected PathSegmentType $type = PathSegmentType::Raw, ) { - if ($content instanceof SpecialSegment) { - $this->content = $content->value; - $this->specialSegment = $content; - } else { - $this->content = $content; + if (PathSegmentType::Root === $type) { + $this->value = null; } } public function getContent(): string { - return $this->content; + return match ($this->type) { + PathSegmentType::Root => '', + default => $this->value ?? '', + }; + } + + public function getValue(): ?string + { + return $this->value; + } + + private static function createKey( + ?string $value, + PathSegmentType $type, + ): string { + return match ($type) { + PathSegmentType::Raw => $value ?? '', + PathSegmentType::Root => '<root>', + PathSegmentType::Parameter => '<parameter>', + }; + } + + public function getKey(): string + { + return self::createKey($this->value, $this->type); + } + + public function getType(): PathSegmentType + { + return $this->type; + } + + public function setParent(?self $segment): void + { + $this->parent = $segment; } public function getParent(): ?self @@ -72,7 +101,8 @@ class PathSegment public function addChild(self $segment): void { - $this->children[$segment->getContent()] = $segment; + $segment->setParent($this); + $this->children[$segment->getKey()] = $segment; } /** @return array<string, self> */ @@ -81,7 +111,68 @@ class PathSegment return $this->children; } - public function addRoute(): void + public function getChildrenCount(): int + { + return count($this->children); + } + + public function findChild( + ?string $value = null, + ): ?PathSegment { + $key = self::createKey($value, PathSegmentType::Raw); + $seg = $this->children[$key] ?? null; + + if ($seg instanceof self) { + return $seg; + } + + $key = self::createKey($value, PathSegmentType::Parameter); + $seg = $this->children[$key] ?? null; + + return $seg; + } + + public function child( + ?string $value = null, + PathSegmentType $type = PathSegmentType::Raw, + ): self { + $key = self::createKey($value, $type); + $seg = $this->children[$key] ?? null; + + if (null === $seg) { + $seg = new PathSegment($value, $type); + } + + $this->addChild($seg); + + return $seg; + } + + public function getPath(): string + { + $parts = array_map( + fn ($seg) => $seg->getContent(), + $this->getAncestorsAndSelf() + ); + + return implode('/', $parts); + } + + public function addRoute(Route $route): void + { + $route->setSegment($this); + $method = $route->getMethod()->value; + $this->routes[$method] = $route; + } + + public function getRoute(HttpMethod $method): ?Route + { + return $this->routes[$method->value] ?? null; + } + + /** @return array<value-of<HttpMethod>, Route> */ + public function getRoutes(): array { + return $this->routes; } } diff --git a/src/PathSegmentMatch.php b/src/PathSegmentMatch.php new file mode 100644 index 0000000..738b4a5 --- /dev/null +++ b/src/PathSegmentMatch.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Lightscale\Router; + +readonly class PathSegmentMatch +{ + /** @param array<string, string> $parameters */ + public function __construct( + public PathSegment $segment, + public array $parameters, + ) { + } +} diff --git a/src/Route.php b/src/Route.php index 1a9ad03..aeb3f66 100644 --- a/src/Route.php +++ b/src/Route.php @@ -6,16 +6,48 @@ namespace Lightscale\Router; use Closure; use Lightscale\Router\Enums\HttpMethod; +use Psr\Http\Message\RequestInterface; class Route { protected Closure $handler; + protected PathSegment $segment; + public function __construct( - protected PathSegment $segment, protected HttpMethod $method, callable $handler, + ?PathSegment $segment = null, ) { $this->handler = Closure::fromCallable($handler); + + if (null !== $segment) { + $this->segment = $segment; + } + } + + public function __invoke(RequestInterface $request): mixed + { + return ($this->handler)($request); + } + + public function getMethod(): HttpMethod + { + return $this->method; + } + + public function setSegment(PathSegment $segment): void + { + $this->segment = $segment; + } + + public function getSegment(): PathSegment + { + return $this->segment; + } + + public function getPath(): string + { + return $this->segment->getPath(); } } diff --git a/src/Router.php b/src/Router.php index efcccfa..f666fe5 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,6 +4,77 @@ declare(strict_types=1); namespace Lightscale\Router; +use Lightscale\Router\Enums\HttpMethod; +use Lightscale\Router\Enums\PathSegmentType; +use Lightscale\Router\Exceptions\NotFoundException; +use Lightscale\Router\Exceptions\UnknownMethodException; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + class Router { + private PathSegment $root; + + public function __construct() + { + $this->root = new PathSegment(type: PathSegmentType::Root); + } + + public function root(): PathSegment + { + return $this->root; + } + + /** @return string[] */ + private function splitPath(string $path): array + { + $split = explode('/', rtrim($path, '/')); + array_shift($split); + + return $split; + } + + public function findSegment(string $path): ?PathSegmentMatch + { + $pathSplit = $this->splitPath($path); + $seg = $this->root(); + $params = []; + + while (($v = array_shift($pathSplit)) !== null && null !== $seg) { + $seg = $seg->findChild($v); + + if (PathSegmentType::Parameter === $seg?->getType()) { + $params[$seg->getValue() ?? ''] = $v; + } + } + + return null === $seg ? null : new PathSegmentMatch( + segment: $seg, + parameters: $params + ); + } + + public function dispatch(RequestInterface $request): ResponseInterface + { + $uri = $request->getUri(); + $match = $this->findSegment($uri->getPath()); + + if (null === $match) { + throw new NotFoundException(); + } + + $segment = $match->segment; + $method = $request->getMethod(); + $method = ( + HttpMethod::tryFrom(strtolower($method)) ?? + throw new UnknownMethodException() + ); + $route = $segment->getRoute($method); + + if (null === $route) { + throw new NotFoundException(); + } + + return $route($request); + } } diff --git a/tests/Unit/PathSegmentTest.php b/tests/Unit/PathSegmentTest.php index 1d19e0e..c537c38 100644 --- a/tests/Unit/PathSegmentTest.php +++ b/tests/Unit/PathSegmentTest.php @@ -2,7 +2,10 @@ declare(strict_types=1); +use Lightscale\Router\Enums\HttpMethod; +use Lightscale\Router\Enums\PathSegmentType; use Lightscale\Router\PathSegment; +use Lightscale\Router\Route; it('initializes') ->expect(fn () => new PathSegment('test')) @@ -12,11 +15,60 @@ it('can get the content') ->expect(fn () => (new PathSegment('test'))->getContent()) ->toBe('test'); -it('can get the parent') - ->expect(fn () => (new PathSegment( - 'test1', - new PathSegment('test2') - ))->getParent()) +it('can be root') + ->expect((new PathSegment(type: PathSegmentType::Root))->getType()) + ->toBe(PathSegmentType::Root); + +it('nulls value when type root') + ->expect(fn () => (new PathSegment('testing', PathSegmentType::Root))->getValue()) + ->toBeNull(); + +it('gets value for type :dataset', function (PathSegmentType $type, ?string $res) { + $seg = new PathSegment($v = 'testing', $type); + expect($seg->getValue())->toBe($res); +})->with([ + ($t = PathSegmentType::Raw)->name => [$t, 'testing'], + ($t = PathSegmentType::Root)->name => [$t, null], + ($t = PathSegmentType::Parameter)->name => [$t, 'testing'], +]); + +it('gets key for type :dataset', function (PathSegmentType $type, ?string $res) { + $seg = new PathSegment($v = 'testing', $type); + expect($seg->getKey())->toBe($res); +})->with([ + ($t = PathSegmentType::Raw)->name => [$t, 'testing'], + ($t = PathSegmentType::Root)->name => [$t, '<root>'], + ($t = PathSegmentType::Parameter)->name => [$t, '<parameter>'], +]); + +it('gets type :dataset', function (PathSegmentType $type) { + $seg = new PathSegment($v = 'testing', $type); + expect($seg->getType())->toBe($type); +})->with(function (): Generator { + foreach (PathSegmentType::cases() as $t) { + yield $t->name => $t; + } +}); + +it('defaults type to Raw') + ->expect(fn () => (new PathSegment('testing'))->getType()) + ->toBe(PathSegmentType::Raw); + +$testChain = function () { + $root = new PathSegment(type: PathSegmentType::Root); + $seg1 = new PathSegment('test1'); + $seg2 = new PathSegment('test2'); + $seg3 = new PathSegment('test3'); + + $seg3->setParent($seg2); + $seg2->setParent($seg1); + $seg1->setParent($root); + + return $seg3; +}; + +it('can have a parent') + ->expect(fn () => $testChain()->getParent()) ->toBeInstanceOf(PathSegment::class) ->getContent()->toBe('test2'); @@ -24,45 +76,154 @@ it('has null parent by default') ->expect(fn () => (new PathSegment('test'))->getParent()) ->toBeNull(); -$testChain = fn () => new PathSegment( - 'test3', - new PathSegment( - 'test2', - new PathSegment( - 'test1' - ) - ) -); - it('can get all ancestors') ->expect(fn () => $testChain()->getAncestors()) ->toBeArray() ->toContainOnlyInstancesOf(PathSegment::class) - ->toHaveCount(2); + ->toHaveCount(3); it('order all ancestors root first') ->expect(fn () => $testChain()->getAncestors()) - ->{0}->getContent()->toBe('test1') - ->{1}->getContent()->toBe('test2'); + ->{0}->getContent()->toBe('') + ->{0}->getType()->toBe(PathSegmentType::Root) + ->{1}->getContent()->toBe('test1') + ->{2}->getContent()->toBe('test2'); it('can get all ancestors and self') ->expect(fn () => $testChain()->getAncestorsAndSelf()) ->toBeArray() ->toContainOnlyInstancesOf(PathSegment::class) - ->toHaveCount(3); + ->toHaveCount(4); it('order all ancestors and self root first') ->expect(fn () => $testChain()->getAncestorsAndSelf()) - ->{0}->getContent()->toBe('test1') - ->{1}->getContent()->toBe('test2') - ->{2}->getContent()->toBe('test3'); + ->{0}->getContent()->toBe('') + ->{0}->getType()->toBe(PathSegmentType::Root) + ->{1}->getContent()->toBe('test1') + ->{2}->getContent()->toBe('test2') + ->{3}->getContent()->toBe('test3'); + +$makeWithChildren = function () { + $seg = new PathSegment('test1'); + $seg->addChild(new PathSegment('test2')); + $seg->addChild(new PathSegment('test3')); + + return $seg; +}; + +it('can have children') + ->expect(fn () => $makeWithChildren()->getChildren()) + ->toBeArray() + ->toContainOnlyInstancesOf(PathSegment::class) + ->{'test2'}->getContent()->toBe('test2') + ->{'test3'}->getContent()->toBe('test3'); -it('can have children')->todo(); +it('when adding child sets its parent') + ->expect(fn () => $makeWithChildren()->getChildren()) + ->{'test2'}->getParent()->toBeInstanceOf(PathSegment::class); -it('can build full path')->todo(); +it('gets the children count') + ->expect(fn () => $makeWithChildren()->getChildrenCount()) + ->toBe(2); -it('can have routes')->todo(); +it('can build full path') + ->expect(fn () => $testChain()->getPath()) + ->toBe('/test1/test2/test3'); -it('can get routes by method')->todo(); +it('creates new child and returns') + ->expect(fn () => (new PathSegment('test1'))->child('test2')) + ->toBeInstanceOf(PathSegment::class) + ->getContent()->toBe('test2'); -it('can get routes for all methods')->todo(); +it('creates new child added to children') + ->expect(function () { + $seg = new PathSegment('test1'); + $seg->child('test2'); + + return $seg->getChildren(); + }) + ->toHaveCount(1); + +it('does not add same child twice', function () { + $seg = new PathSegment('test1'); + $seg2 = $seg->child('test2'); + $seg3 = $seg->child('test2'); + + expect($seg2)->toBe($seg3); + expect($seg->getChildrenCount())->toBe(1); +}); + +it('can get a raw segment child', function () { + $seg = new PathSegment('test1'); + $c1 = $seg->child('test2'); + $c2 = $seg->child('test3'); + + expect($seg->findChild('test2'))->toBe($c1); + expect($seg->findChild('test3'))->toBe($c2); +}); + +it('can get a parameter segment child', function () { + $seg = new PathSegment('test1'); + $c1 = $seg->child('test2'); + $c2 = $seg->child('test3', PathSegmentType::Parameter); + + expect($seg->findChild('test2'))->toBe($c1); + expect($seg->findChild('test4'))->toBe($c2); +}); + +it('sets route segment when added', function () { + $seg = new PathSegment('test1'); + $seg->addRoute($r = new Route( + HttpMethod::Get, + fn () => null + )); + + expect($r->getSegment())->toBe($seg); +}); + +it('can have routes', function () { + $seg = new PathSegment('test'); + $seg->addRoute($r1 = new Route( + HttpMethod::Get, + fn () => null, + )); + $seg->addRoute($r2 = new Route( + HttpMethod::Post, + fn () => null, + )); + + expect($seg->getRoutes()) + ->toHaveCount(2) + ->{HttpMethod::Get->value}->toBe($r1) + ->{HttpMethod::Post->value}->toBe($r2); +}); + +it('overwrites existing routes', function () { + $seg = new PathSegment('test'); + $seg->addRoute($r1 = new Route( + HttpMethod::Get, + fn () => null, + )); + $seg->addRoute($r2 = new Route( + HttpMethod::Get, + fn () => null, + )); + + expect($seg->getRoutes()) + ->toHaveCount(1) + ->{HttpMethod::Get->value}->toBe($r2); +}); + +it('can get route by method', function () { + $seg = new PathSegment('test'); + $seg->addRoute($r = new Route( + HttpMethod::Get, + fn () => null, + )); + + expect($seg->getRoute(HttpMethod::Get))->toBe($r); +}); + +it('gets null with route method that doesn\'t exist') + ->expect(fn () => (new PathSegment('test'))->getRoute(HttpMethod::Get)) + ->toBeNull(); diff --git a/tests/Unit/RouteTest.php b/tests/Unit/RouteTest.php index 0414649..104401b 100644 --- a/tests/Unit/RouteTest.php +++ b/tests/Unit/RouteTest.php @@ -3,14 +3,56 @@ declare(strict_types=1); use Lightscale\Router\Enums\HttpMethod; -use Lightscale\Router\Enums\SpecialSegment; +use Lightscale\Router\Enums\PathSegmentType; use Lightscale\Router\PathSegment; use Lightscale\Router\Route; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\RequestInterface; it('initializes') ->expect(fn () => new Route( - new PathSegment(SpecialSegment::Root), HttpMethod::Get, - fn () => null + fn () => null, + new PathSegment(type: PathSegmentType::Root), )) ->toBeInstanceOf(Route::class); + +it('gets segment') + ->expect(fn () => (new Route( + HttpMethod::Get, + fn () => null, + new PathSegment(type: PathSegmentType::Root), + ))->getSegment()) + ->toBeInstanceOf(PathSegment::class); + +it('throws getting segment without being set', function () { + $r = new Route(HttpMethod::Get, fn () => null); + $r->getSegment(); +})->throws(Error::class); + +it('can set segment') + ->expect(function () { + $r = new Route(HttpMethod::Get, fn () => null); + $r->setSegment(new PathSegment('test')); + + return $r->getSegment(); + }) + ->toBeInstanceOf(PathSegment::class) + ->getValue()->toBe('test'); + +it('gets method') + ->expect(fn () => (new Route( + HttpMethod::Get, + fn () => null, + ))->getMethod()) + ->toBe(HttpMethod::Get); + +it('can call handler') + ->expect(fn () => (new Route( + HttpMethod::Get, + fn (RequestInterface $req) => $req->getUri()->getPath() + ))( + (new Psr17Factory())->createRequest(HttpMethod::Get->value, '/testing') + ) + ) + ->toBe('/testing'); diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php new file mode 100644 index 0000000..5d08441 --- /dev/null +++ b/tests/Unit/RouterTest.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +use Lightscale\Router\Enums\PathSegmentType; +use Lightscale\Router\PathSegment; +use Lightscale\Router\Router; + +it('initializes') + ->expect(fn () => new Router()) + ->toBeInstanceOf(Router::class); + +it('has root') + ->expect(fn () => (new Router())->root()) + ->toBeInstanceOf(PathSegment::class) + ->getType()->toBe(PathSegmentType::Root); + +it('finds root segment with :dataset path', function (string $path) { + $router = new Router(); + expect($router->findSegment($path))->segment->toBe($router->root()); +})->with(['', '/']); + +it('finds segment one level', function () { + $router = new Router(); + $c1 = $router->root()->child($p1 = 'test1'); + $c2 = $router->root()->child($p2 = 'test2'); + + expect($router->findSegment("/{$p1}"))->segment->toBe($c1); + expect($router->findSegment("/{$p2}"))->segment->toBe($c2); +}); + +it('finds segment one level parameter', function () { + $router = new Router(); + $c1 = $router->root()->child('test2', PathSegmentType::Parameter); + + expect($router->findSegment('/anything')) + ->segment->toBe($c1) + ->parameters->{'test2'}->toBe('anything'); +}); + +it('finds segment one falls back to parameter', function () { + $router = new Router(); + $c1 = $router->root()->child($p1 = 'test1'); + $c2 = $router->root()->child('test2', PathSegmentType::Parameter); + + expect($router->findSegment("/{$p1}"))->segment->toBe($c1); + expect($router->findSegment('/anything')) + ->segment->toBe($c2) + ->parameters->{'test2'}->toBe('anything'); +}); + +it('finds segments in complex routes', function () { + $router = new Router(); + + $r = $router->root(); + + $s1 = $r->child('level1-1')->child('level2-1'); + $s2 = $r->child('level1-1')->child('level2-2'); + $s3 = $r->child('level1-2')->child('level2-3'); + $s4 = $r->child('level1-1')->child('level2-2')->child('level3-1'); + $s5 = $r->child('level1-1'); + $s6 = $r->child('level1-3')->child('level2-4', PathSegmentType::Parameter); + $s7 = $s6->child('level3-2'); + $s8 = $s6->child('level3-3'); + + expect($router->findSegment('/level1-1')?->segment)->toBe($s5); + expect($router->findSegment('/level1-2/level2-3')?->segment)->toBe($s3); + expect($router->findSegment('/level1-1/level2-2/level3-1')?->segment)->toBe($s4); + expect($router->findSegment('/level1-1/level2-1')?->segment)->toBe($s1); + expect($router->findSegment('/level1-1/level2-2')?->segment)->toBe($s2); + expect($router->findSegment('/level1-3/param1')) + ->segment->toBe($s6) + ->parameters->{'level2-4'}->toBe('param1'); + expect($router->findSegment('/level1-3/param2/level3-2')) + ->segment->toBe($s7) + ->parameters->{'level2-4'}->toBe('param2'); + expect($router->findSegment('/level1-3/param3/level3-3')) + ->segment->toBe($s8) + ->parameters->{'level2-4'}->toBe('param3'); +}); + +it('return null when segment not found', function () { + $router = new Router(); + $r = $router->root(); + $r->child('level1-1')->child('level2-1'); + $r->child('level1-1')->child('level2-2'); + $r->child('level1-2')->child('level2-3'); + + expect($router->findSegment('/testing/testing'))->toBeNull(); +}); |
