From 8c0efd0d9317ad92bd55cd6afcd41bdbab827bf8 Mon Sep 17 00:00:00 2001 From: Sam Light Date: Wed, 10 Jun 2026 19:00:32 +0100 Subject: Make basic routing work --- composer.json | 9 +- composer.lock | 296 +++++++++++++++++++++++++++++++++++- phpstan.neon | 8 +- src/BasicStrategy.php | 23 +++ src/Contracts/Strategy.php | 15 ++ src/Route.php | 11 +- src/RouteCall.php | 17 +++ src/Router.php | 77 +++++++++- tests/Unit/BasicStrategyTest.php | 44 ++++++ tests/Unit/PathSegmentMatchTest.php | 17 +++ tests/Unit/RouteCallTest.php | 22 +++ tests/Unit/RouteTest.php | 14 +- tests/Unit/RouterTest.php | 56 +++++++ tests/Utils/TestCall.php | 16 ++ tests/Utils/TestCallable.php | 69 +++++++++ 15 files changed, 664 insertions(+), 30 deletions(-) create mode 100644 src/BasicStrategy.php create mode 100644 src/Contracts/Strategy.php create mode 100644 src/RouteCall.php create mode 100644 tests/Unit/BasicStrategyTest.php create mode 100644 tests/Unit/PathSegmentMatchTest.php create mode 100644 tests/Unit/RouteCallTest.php create mode 100644 tests/Utils/TestCall.php create mode 100644 tests/Utils/TestCallable.php diff --git a/composer.json b/composer.json index 6ecde7c..ac85123 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,16 @@ "phpstan/phpstan": "^2.2", "friendsofphp/php-cs-fixer": "^3.95", "ace-of-aces/intellipest": "^1.0", - "nyholm/psr7": "^1.8" + "nyholm/psr7": "^1.8", + "mockery/mockery": "^1.6", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-mockery": "^2.0", + "mrpunyapal/peststan": "^0.2.10" }, "config": { "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true } }, "scripts": { diff --git a/composer.lock b/composer.lock index 707d8d0..c76f8e8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1b580d80d8e453e316c8a11cd22f4157", + "content-hash": "207c08be14a5ab20d538c75ead372f1d", "packages": [ { "name": "psr/http-message", @@ -891,6 +891,57 @@ ], "time": "2026-05-29T20:35:26+00:00" }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -951,6 +1002,152 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "mrpunyapal/peststan", + "version": "0.2.10", + "source": { + "type": "git", + "url": "https://github.com/MrPunyapal/PestStan.git", + "reference": "750859a911050915cb6e3efbfde30e900ad717bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MrPunyapal/PestStan/zipball/750859a911050915cb6e3efbfde30e900ad717bf", + "reference": "750859a911050915cb6e3efbfde30e900ad717bf", + "shasum": "" + }, + "require": { + "php": "^8.2", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "laravel/pint": "^1.18", + "mrpunyapal/rector-pest": "^0.2.0", + "nunomaduro/pao": "^0.1.4", + "pestphp/pest": "^3.0 || ^4.0 || ^5.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-strict-rules": "^2.0", + "rector/rector": "^2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PestStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan extension for Pest PHP testing framework", + "keywords": [ + "PHPStan", + "pest", + "static-analysis", + "testing" + ], + "support": { + "issues": "https://github.com/MrPunyapal/PestStan/issues", + "source": "https://github.com/MrPunyapal/PestStan/tree/0.2.10" + }, + "funding": [ + { + "url": "https://github.com/mrpunyapal", + "type": "github" + } + ], + "time": "2026-05-10T16:55:11+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -2015,6 +2212,54 @@ }, "time": "2026-01-06T21:53:42+00:00" }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.2", @@ -2126,6 +2371,55 @@ ], "time": "2026-05-28T14:44:12+00:00" }, + { + "name": "phpstan/phpstan-mockery", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-mockery.git", + "reference": "89a949d0ac64298e88b7c7fa00caee565c198394" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-mockery/zipball/89a949d0ac64298e88b7c7fa00caee565c198394", + "reference": "89a949d0ac64298e88b7c7fa00caee565c198394", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "mockery/mockery": "^1.6.11", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan Mockery extension", + "support": { + "issues": "https://github.com/phpstan/phpstan-mockery/issues", + "source": "https://github.com/phpstan/phpstan-mockery/tree/2.0.0" + }, + "time": "2024-10-14T03:18:12+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "12.5.6", diff --git a/phpstan.neon b/phpstan.neon index 6e02a9e..bbd57ba 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,11 +5,7 @@ parameters: - tests ignoreErrors: - - 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 + message: '#Call to an undefined method Pest\\Expectation<#' path: tests + identifier: method.notFound reportUnmatched: false diff --git a/src/BasicStrategy.php b/src/BasicStrategy.php new file mode 100644 index 0000000..62bc48e --- /dev/null +++ b/src/BasicStrategy.php @@ -0,0 +1,23 @@ +route->getHandler())($call); + } + + public function notFound(RequestInterface $request): ResponseInterface + { + throw new NotFoundException(); + } +} diff --git a/src/Contracts/Strategy.php b/src/Contracts/Strategy.php new file mode 100644 index 0000000..81d6ec3 --- /dev/null +++ b/src/Contracts/Strategy.php @@ -0,0 +1,15 @@ +handler)($request); - } - public function getMethod(): HttpMethod { return $this->method; @@ -50,4 +44,9 @@ class Route { return $this->segment->getPath(); } + + public function getHandler(): Closure + { + return $this->handler; + } } diff --git a/src/RouteCall.php b/src/RouteCall.php new file mode 100644 index 0000000..b76a780 --- /dev/null +++ b/src/RouteCall.php @@ -0,0 +1,17 @@ + $parameters */ + public function __construct( + public RequestInterface $request, + public Route $route, + public array $parameters, + ) {} +} diff --git a/src/Router.php b/src/Router.php index f666fe5..ddd3220 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Lightscale\Router; +use Lightscale\Router\Contracts\Strategy; use Lightscale\Router\Enums\HttpMethod; use Lightscale\Router\Enums\PathSegmentType; use Lightscale\Router\Exceptions\NotFoundException; @@ -14,10 +15,22 @@ use Psr\Http\Message\ResponseInterface; class Router { private PathSegment $root; + private Strategy $strategy; public function __construct() { $this->root = new PathSegment(type: PathSegmentType::Root); + $this->strategy = new BasicStrategy; + } + + public function getStrategy(): Strategy + { + return $this->strategy; + } + + public function setStrategy(Strategy $strategy): void + { + $this->strategy = $strategy; } public function root(): PathSegment @@ -60,21 +73,71 @@ class Router $match = $this->findSegment($uri->getPath()); if (null === $match) { - throw new NotFoundException(); + return $this->strategy->notFound($request); } $segment = $match->segment; $method = $request->getMethod(); - $method = ( - HttpMethod::tryFrom(strtolower($method)) ?? - throw new UnknownMethodException() - ); + $method = HttpMethod::tryFrom(strtolower($method)); + + if ($method === null) { + return $this->strategy->notFound($request); + } + $route = $segment->getRoute($method); if (null === $route) { - throw new NotFoundException(); + return $this->strategy->notFound($request); + } + + $call = new RouteCall( + $request, + $route, + $match->parameters, + ); + + return $this->strategy->runRoute($call); + } + + public function route(HttpMethod $method, string $path, callable $handler): void + { + $pathSplit = $this->splitPath($path); + + $seg = $this->root(); + while(($v = array_shift($pathSplit)) !== null) { + $seg = $seg->child($v); } - return $route($request); + $seg->addRoute(new Route($method, $handler)); + } + + public function get(string $path, callable $handler): void + { + $this->route(HttpMethod::Get, $path, $handler); + } + + public function post(string $path, callable $handler): void + { + $this->route(HttpMethod::Post, $path, $handler); + } + + public function put(string $path, callable $handler): void + { + $this->route(HttpMethod::Put, $path, $handler); + } + + public function patch(string $path, callable $handler): void + { + $this->route(HttpMethod::Patch, $path, $handler); + } + + public function delete(string $path, callable $handler): void + { + $this->route(HttpMethod::Delete, $path, $handler); + } + + public function any(string $path, callable $handler): void + { + $this->route(HttpMethod::Any, $path, $handler); } } diff --git a/tests/Unit/BasicStrategyTest.php b/tests/Unit/BasicStrategyTest.php new file mode 100644 index 0000000..2a0b550 --- /dev/null +++ b/tests/Unit/BasicStrategyTest.php @@ -0,0 +1,44 @@ +expect(fn() => new BasicStrategy) + ->toBeInstanceOf(BasicStrategy::class); + +it('throws on not found', function() { + (new BasicStrategy)->notFound( + (new Psr17Factory)->createServerRequest( + HttpMethod::Get->value, + '/testing/testing' + ) + ); +})->throws(NotFoundException::class); + +it('calls route', function () { + $factory = new Psr17Factory; + $response = $factory->createResponse(200, 'OK'); + $cb = TestCallable::make(fn () => $response); + $res = (new BasicStrategy)->runRoute($rc = new RouteCall( + $factory->createServerRequest( + HttpMethod::Get->value, + '/testing/testing' + ), + new Route( + HttpMethod::Get, + $cb + ), + [] + )); + + $cb->assertIsCalled(); + $call = $cb->getLastCall(); + expect($call->args[0] ?? null)->toBe($rc); + expect($res)->toBe($response); +}); diff --git a/tests/Unit/PathSegmentMatchTest.php b/tests/Unit/PathSegmentMatchTest.php new file mode 100644 index 0000000..9a522a4 --- /dev/null +++ b/tests/Unit/PathSegmentMatchTest.php @@ -0,0 +1,17 @@ + 'test2'], + ); + + expect($match) + ->segment->toBe($seg) + ->parameters->toBe($params); +}); diff --git a/tests/Unit/RouteCallTest.php b/tests/Unit/RouteCallTest.php new file mode 100644 index 0000000..70cc50e --- /dev/null +++ b/tests/Unit/RouteCallTest.php @@ -0,0 +1,22 @@ +createServerRequest(HttpMethod::Get->value, '/test/test'), + route: $route = new Route(HttpMethod::Get, fn () => null), + parameters: $params = ['test1' => 'test2'], + ); + + expect($call) + ->request->toBe($req) + ->route->toBe($route) + ->parameters->toBe($params); +}); diff --git a/tests/Unit/RouteTest.php b/tests/Unit/RouteTest.php index 104401b..50513a1 100644 --- a/tests/Unit/RouteTest.php +++ b/tests/Unit/RouteTest.php @@ -47,12 +47,10 @@ it('gets method') ))->getMethod()) ->toBe(HttpMethod::Get); -it('can call handler') - ->expect(fn () => (new Route( +it('can get handler', function() { + $r = new Route( HttpMethod::Get, - fn (RequestInterface $req) => $req->getUri()->getPath() - ))( - (new Psr17Factory())->createRequest(HttpMethod::Get->value, '/testing') - ) - ) - ->toBe('/testing'); + $h = fn (RequestInterface $req) => $req->getUri()->getPath() + ); + expect($r->getHandler())->toBe($h); +}); diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php index 5d08441..12e27a4 100644 --- a/tests/Unit/RouterTest.php +++ b/tests/Unit/RouterTest.php @@ -2,14 +2,32 @@ declare(strict_types=1); +use Lightscale\Router\BasicStrategy; +use Lightscale\Router\Contracts\Strategy; +use Lightscale\Router\Enums\HttpMethod; use Lightscale\Router\Enums\PathSegmentType; use Lightscale\Router\PathSegment; +use Lightscale\Router\Route; +use Lightscale\Router\RouteCall; use Lightscale\Router\Router; +use Nyholm\Psr7\Factory\Psr17Factory; it('initializes') ->expect(fn () => new Router()) ->toBeInstanceOf(Router::class); +it('initializes with basic strategy') + ->expect((new Router)->getStrategy()) + ->toBeInstanceOf(BasicStrategy::class); + +it('can set strategy', function() { + $router = new Router; + $s = new BasicStrategy; + expect($router->getStrategy())->not->toBe($s); + $router->setStrategy($s); + expect($router->getStrategy())->toBe($s); +}); + it('has root') ->expect(fn () => (new Router())->root()) ->toBeInstanceOf(PathSegment::class) @@ -88,3 +106,41 @@ it('return null when segment not found', function () { expect($router->findSegment('/testing/testing'))->toBeNull(); }); + +it('calls strategy notFound with dispatch', function () { + $router = new Router(); + $factory = new Psr17Factory; + $request = $factory->createServerRequest(HttpMethod::Get->value, '/testing/testing'); + $response = $factory->createResponse(); + + $strat = Mockery::mock(Strategy::class); + $strat->shouldReceive('notFound') + ->with($request) + ->andReturn($response); + + $router->setStrategy($strat); + $result = $router->dispatch($request); + expect($result)->toBe($response); +}); + +it('calls strategy runRoute with dispatch on match', function() { + $router = new Router(); + + $router->root()->child('testing')->addRoute(new Route( + HttpMethod::Get, + fn () => null, + )); + + $factory = new Psr17Factory; + $request = $factory->createServerRequest(HttpMethod::Get->value, '/testing'); + $response = $factory->createResponse(); + + $strat = Mockery::mock(Strategy::class); + $strat->shouldReceive('runRoute') + ->with(Mockery::type(RouteCall::class)) + ->andReturn($response); + + $router->setStrategy($strat); + $result = $router->dispatch($request); + expect($result)->toBe($response); +}); diff --git a/tests/Utils/TestCall.php b/tests/Utils/TestCall.php new file mode 100644 index 0000000..971e6a7 --- /dev/null +++ b/tests/Utils/TestCall.php @@ -0,0 +1,16 @@ +cb = Closure::fromCallable($cb); + } + + public static function make(callable $cb): static + { + return new static($cb); + } + + public function __invoke(mixed ...$args): mixed + { + $call = new TestCall( + args: $args, + return: ($this->cb)(...$args), + ); + $this->calls[] = $call; + + return $call->return; + } + + public function getCallCount(): int + { + return count($this->calls); + } + + /** @return TestCall[] */ + public function getCalls(): array + { + return $this->calls; + } + + public function getLastCall(): ?TestCall + { + return $this->calls[$this->getCallCount() - 1] ?? null; + } + + public function assertIsCalled(): void + { + Assert::assertGreaterThan(0, $this->getCallCount(), 'Not been called'); + } + + public function assertNotCalled(): void + { + Assert::assertSame(0, $this->getCallCount()); + } + + public function assertCalledTimes(int $amount): void + { + Assert::assertSame($amount, $this->getCallCount()); + } + +} -- cgit v1.2.3