From c9496797f40bb04e36ea51fa327e9c586a368514 Mon Sep 17 00:00:00 2001 From: Alireza Salehizadeh Date: Tue, 31 Oct 2023 22:38:55 +0330 Subject: [PATCH] initial commit --- .gitignore | 9 + CHANGELOG.md | 8 + CONTRIBUTING.md | 24 +++ LICENSE.md | 21 +++ README.md | 146 +++++++++++++++ art/routail.png | Bin 0 -> 11389 bytes composer.json | 37 ++++ src/Compiler/RoutePatternCompiler.php | 109 +++++++++++ src/Enums/HttpMethod.php | 12 ++ src/Enums/ParameterType.php | 22 +++ src/Exceptions/BadHttpMethodCallException.php | 16 ++ .../InvalidParameterTypeException.php | 15 ++ src/Exceptions/MiddlewareException.php | 15 ++ src/Exceptions/RouteNotFoundException.php | 15 ++ src/Generator/UrlGenerator.php | 62 +++++++ src/Handler/ActionManager.php | 26 +++ src/Handler/ArrayActionHandler.php | 16 ++ src/Handler/ClosureActionHandler.php | 15 ++ src/Handler/StringActionHandler.php | 17 ++ src/Matcher/RouteMatcher.php | 59 ++++++ src/Middleware/Middleware.php | 52 ++++++ src/Middleware/MiddlewareInterface.php | 10 + src/Request.php | 28 +++ src/Route.php | 49 +++++ src/RouteCollection.php | 52 ++++++ src/Router.php | 173 ++++++++++++++++++ tests/Compiler/RoutePatternCompilerTest.php | 33 ++++ tests/Fixtures/Middleware/BarMiddleware.php | 17 ++ tests/Fixtures/Middleware/BazMiddleware.php | 17 ++ tests/Fixtures/Middleware/FooMiddleware.php | 17 ++ tests/Fixtures/TestController.php | 13 ++ tests/Generator/UrlGeneratorTest.php | 27 +++ tests/MiddlewareTest.php | 43 +++++ tests/RouteCollectionTest.php | 27 +++ tests/RouteMatcherTest.php | 50 +++++ tests/RouterTest.php | 122 ++++++++++++ 36 files changed, 1374 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100755 LICENSE.md create mode 100644 README.md create mode 100644 art/routail.png create mode 100644 composer.json create mode 100644 src/Compiler/RoutePatternCompiler.php create mode 100644 src/Enums/HttpMethod.php create mode 100644 src/Enums/ParameterType.php create mode 100644 src/Exceptions/BadHttpMethodCallException.php create mode 100644 src/Exceptions/InvalidParameterTypeException.php create mode 100644 src/Exceptions/MiddlewareException.php create mode 100644 src/Exceptions/RouteNotFoundException.php create mode 100644 src/Generator/UrlGenerator.php create mode 100644 src/Handler/ActionManager.php create mode 100644 src/Handler/ArrayActionHandler.php create mode 100644 src/Handler/ClosureActionHandler.php create mode 100644 src/Handler/StringActionHandler.php create mode 100644 src/Matcher/RouteMatcher.php create mode 100644 src/Middleware/Middleware.php create mode 100644 src/Middleware/MiddlewareInterface.php create mode 100644 src/Request.php create mode 100644 src/Route.php create mode 100644 src/RouteCollection.php create mode 100644 src/Router.php create mode 100644 tests/Compiler/RoutePatternCompilerTest.php create mode 100644 tests/Fixtures/Middleware/BarMiddleware.php create mode 100644 tests/Fixtures/Middleware/BazMiddleware.php create mode 100644 tests/Fixtures/Middleware/FooMiddleware.php create mode 100644 tests/Fixtures/TestController.php create mode 100644 tests/Generator/UrlGeneratorTest.php create mode 100644 tests/MiddlewareTest.php create mode 100644 tests/RouteCollectionTest.php create mode 100644 tests/RouteMatcherTest.php create mode 100644 tests/RouterTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e9998e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.phpunit.result.cache +/.phpunit.cache +/.php-cs-fixer.cache +/.php-cs-fixer.php +/composer.lock +/phpunit.xml +/vendor/ +*.swp +*.swo diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15dd6ee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] +- Adds first version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c05099 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# CONTRIBUTING + +Contributions are welcome, and are accepted via pull requests. +Please review these guidelines before submitting any pull requests. + +## Process + +1. Fork the project +2. Create a new branch +3. Code, test, commit and push +4. Open a pull request detailing your changes. Make sure to follow the [template](.github/PULL_REQUEST_TEMPLATE.md) + +## Guidelines + +* Send a coherent commit history, making sure each individual commit in your pull request is meaningful. +* You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. +* Please remember that we follow [SemVer](http://semver.org/). + +## Setup + +Clone your fork, then install the dev dependencies: +```bash +composer install +``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..83d14df --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Alireza Salehizadeh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4218d65 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +

+ + +A spidey PHP router like Tarantulas +

+ +## Features +* Support `get`, `post`, `put`, `patch`, `delete` and `any` method +* Support optional parameter +* Middlewares +* Route group +* Url generator +## Requirements +PHP >= 8.2 + + +## Getting Started + + +#### Installation +via Composer +``` +composer require alirezasalehizadeh/routail +``` + +#### Route definition +The example below, is a quick view of how you can define an route and run it +```php +use AlirezaSalehizadeh\Routail\Router; + +$router = new Router(); + +$router->get(string $pattern, string|array|Closure $action) + ->name(string $name) + ->prefix(string $prefix) + ->middleware(array $middlewares); + +$router->run(); + +``` + +#### Route group definition +```php +use AlirezaSalehizadeh\Routail\Router; + +$router = new Router(); + +$router->group(Closure $action, array $middlewares, string $prefix); + +$router->run(); + +``` +## Usage + +#### Middlewares +To use middlewares, you need to create a class that extends from the `AlirezaSalehizadeh\Routail\Middleware` class and implement the `handle` method that returns a boolean +```php +use AlirezaSalehizadeh\Routail\Request; +use AlirezaSalehizadeh\Routail\Middleware\Middleware; + +class FooMiddleware extends Middleware +{ + public function handle(Request $request): bool + { + return true; + } +} + +``` + +#### Url generator +By `url` method, you can create url from route name easily +```php +use AlirezaSalehizadeh\Routail\Router; + +$router = new Router(); + +$router->get('/users/{id}', 'UserController@show')->name('user_show'); + +$router->url('user_show', ['id' => '1']); + +// output: /users/1 +``` + +#### Route parameter types +Route parameters can have a type, which can be optional +``` +any +id +int +string +uuid +slug +bool +date +int? // optional +any? // optional +``` +## Examples +```php +use AlirezaSalehizadeh\Routail\Router; + +$router = new Router(); + +$router->get('/users', 'UserController@index'); + +$router->any('/users', [UserController::class, 'index']); + +// route pattern with parameter +$router->get('/users/{id}', 'UserController@show'); + +// route pattern with parameter and type +$router->get('/users/{id:int}', function($id){ + return "User id is $id"; +}); + +// route pattern with optional parameter +$router->get('/users/{id:int?}', function($id = 1){ + return "User id is $id"; +}); + +// set name for route +$router->get('/users/{id}', 'UserController@index')->name('user_index'); + +// set prefix for route +$router->get('/users/{id}', 'UserController@index')->prefix('/api/v1'); + +// set middleware for route +$router->get('/users/{id}', 'UserController@index')->middleware([FooMiddleware::class, BarMiddleware::class]); + +// route group +$router->group(function($router){ + $router->get('/users', 'UserController@index'); + $router->get('/users/{id}', 'UserController@show'); +}, [FooMiddleware::class, BarMiddleware::class], '/api/v1'); + + +``` + +## Contributing +Please read the [CONTRIBUTING.md](CONTRIBUTING.md) file. + + +## License + +[MIT](LICENSE.md). diff --git a/art/routail.png b/art/routail.png new file mode 100644 index 0000000000000000000000000000000000000000..c405ae812ec40308e7fce159bb5157b5a22a8dbb GIT binary patch literal 11389 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 '([^/]+)', + 'id' => '\d+', + 'int' => '\d+', + 'string' => '[^/]+', + 'uuid' => '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', + 'slug' => '([\w\-_]+)', + 'bool' => '(true|false|1|0)', + 'date' => '([0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]))', + 'int?' => '(?:/([0-9]+))?', + 'any?' => '(?:/([a-zA-Z0-9\.\-_%=]+))?', + ]; + + public function __construct( + private Route $route + ) { + $this->pattern = $this->route->getPattern(); + } + + public function toRegex(): string + { + if ($this->hasParameter()) { + return $this->compilePatternWithParameter(); + } + + return $this->compilePatternWithoutParameter(); + } + + private function compilePatternWithoutParameter(): string + { + $this->pattern = str_replace('/', '\/', $this->pattern); + return '/^' . $this->pattern . '$/'; + } + + private function compilePatternWithParameter(): string + { + $parameters = $this->getParameters(); + + foreach ($parameters[1] as $parameter) { + if ($this->parameterHaveType($parameter)) { + [$key, $type] = $this->getParameterKeyAndType($parameter); + if ($this->parameterTypeIsValid($type)) { + $this->compilePatternWithParameterAndType($key, $type); + continue; + } + } + $this->compilePatternWithParameterWithoutType($parameter); + } + $this->pattern = str_replace('/', '\/', $this->pattern); + return '/^' . $this->pattern . '$/'; + } + + public function hasParameter(): bool + { + preg_match('/\{(.*?)\}/', $this->pattern, $matches); + return !empty($matches); + } + + private function getParameters(): array + { + preg_match_all('/\{(.*?)\}/', $this->pattern, $matches); + return $matches; + } + + private function parameterHaveType(string $parameter): bool + { + return (bool) preg_match('/\w+:\w+/', $parameter, $matches); + } + + private function getParameterKeyAndType(string $parameter): array + { + return explode(':', $parameter); + } + + private function compilePatternWithParameterWithoutType(string $key) + { + $this->pattern = preg_replace("/\{($key)\}/", '([^/]+)', $this->pattern); + } + + private function compilePatternWithParameterAndType(string $key, string $type) + { + if (str_ends_with($type, '?')) { + $this->pattern = preg_replace("/\{($key):(\w+.)\}/", '.?' . $this->types[$type], $this->pattern); + return; + } + $this->pattern = preg_replace("/\{($key):(\w+)\}/", '(' . $this->types[$type] . ')', $this->pattern); + } + + private function parameterTypeIsValid(string $type): bool + { + return (bool) ParameterType::find($type) ?: throw new InvalidParameterTypeException($type); + } +} diff --git a/src/Enums/HttpMethod.php b/src/Enums/HttpMethod.php new file mode 100644 index 0000000..ef016c4 --- /dev/null +++ b/src/Enums/HttpMethod.php @@ -0,0 +1,12 @@ +method->value}` method is not supported for '{$route->getPattern()}'."); + } +} diff --git a/src/Exceptions/InvalidParameterTypeException.php b/src/Exceptions/InvalidParameterTypeException.php new file mode 100644 index 0000000..507caf1 --- /dev/null +++ b/src/Exceptions/InvalidParameterTypeException.php @@ -0,0 +1,15 @@ +pattern = new RoutePatternCompiler($this->route); + } + + public function generate(array $parameters = []): string + { + $url = $this->route->getPattern(); + + if ($this->pattern->hasParameter()) { + return $this->replaceParameters($parameters); + } + + if ($this->route->method === HttpMethod::GET) { + return $this->urlWithQueryString($url, $parameters); + } + + return $url; + } + + private function replaceParameters(array $parameters): string + { + $url = ''; + + foreach ($parameters as $key => $value) { + if ($url !== '') { + $url = preg_replace("/\{($key)(?:\:([a-zA-Z]+))?\}/", $value, $url); + continue; + } + $url = preg_replace("/\{($key)(?:\:([a-zA-Z]+))?\}/", $value, $this->route->getPattern()); + } + return $url; + } + + private function urlWithQueryString(string $url, array $parameters): string + { + if (count($parameters) === 0) { + return $url; + } + + $queryString = http_build_query($parameters); + + return "{$url}?{$queryString}"; + } +} diff --git a/src/Handler/ActionManager.php b/src/Handler/ActionManager.php new file mode 100644 index 0000000..3ad95cb --- /dev/null +++ b/src/Handler/ActionManager.php @@ -0,0 +1,26 @@ +action instanceof Closure) { + return (new ClosureActionHandler)->handle($route->action, $parameters); + } + + if (is_string($route->action)) { + return (new StringActionHandler)->handle($route->action); + } + + if (is_array($route->action)) { + return (new ArrayActionHandler)->handle($route->action); + } + } +} diff --git a/src/Handler/ArrayActionHandler.php b/src/Handler/ArrayActionHandler.php new file mode 100644 index 0000000..0c577d6 --- /dev/null +++ b/src/Handler/ArrayActionHandler.php @@ -0,0 +1,16 @@ +$method(); + } +} diff --git a/src/Handler/ClosureActionHandler.php b/src/Handler/ClosureActionHandler.php new file mode 100644 index 0000000..2b64bbb --- /dev/null +++ b/src/Handler/ClosureActionHandler.php @@ -0,0 +1,15 @@ +$method(); + } +} diff --git a/src/Matcher/RouteMatcher.php b/src/Matcher/RouteMatcher.php new file mode 100644 index 0000000..10c3de0 --- /dev/null +++ b/src/Matcher/RouteMatcher.php @@ -0,0 +1,59 @@ +collection->getRoutes() as $routeWithOption) { + [$route, $option] = $routeWithOption; + if (!empty($this->matchRoutePattern($route))) { + $matchedRoutes[] = $route; + } + } + + if (!empty($matchedRoutes)) { + foreach ($matchedRoutes as $route) { + if ($this->matchHttpMethod($route)) { + return $route; + } + } + throw new BadHttpMethodCallException($route); + } + + throw new RouteNotFoundException($this->request->getUri()); + } + + private function matchRoutePattern(Route $route): array|null + { + $pattern = (new RoutePatternCompiler($route))->toRegex(); + preg_match($pattern, $this->request->getUri(), $matches); + return $matches; + } + + private function matchHttpMethod(Route $route): bool + { + return $this->request->getMethod() === $route->method->value; + } +} diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php new file mode 100644 index 0000000..3d3d22d --- /dev/null +++ b/src/Middleware/Middleware.php @@ -0,0 +1,52 @@ +middlewares[$name] = $middlewares; + } + + public function addGroup(string $name, array $middlewares) + { + $this->groupMiddlewares[$name] = $middlewares; + } + + public function has(string $name): bool + { + return isset($this->middlewares[$name]); + } + + public function groupHas(string $name): bool + { + return isset($this->groupMiddlewares[$name]); + } + + public function get(string $name): array + { + return $this->middlewares[$name]; + } + + public function getMiddlewares(): array + { + return $this->middlewares; + } + + public function handle(Request $request): bool + { + return true; + } +} diff --git a/src/Middleware/MiddlewareInterface.php b/src/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..950bf37 --- /dev/null +++ b/src/Middleware/MiddlewareInterface.php @@ -0,0 +1,10 @@ +value; + return new static; + } +} diff --git a/src/Route.php b/src/Route.php new file mode 100644 index 0000000..ff140ae --- /dev/null +++ b/src/Route.php @@ -0,0 +1,49 @@ +name = $name; + return $this; + } + + public function getName() + { + if ($this->name === null) { + $this->setName($this->pattern); + } + return $this->name; + } + + public function setPrefix(string $prefix) + { + $this->prefix = $prefix; + return $this; + } + + public function getPattern() + { + if ($this->prefix) { + return $this->prefix . $this->pattern; + } + return $this->pattern; + } +} diff --git a/src/RouteCollection.php b/src/RouteCollection.php new file mode 100644 index 0000000..2d2d82a --- /dev/null +++ b/src/RouteCollection.php @@ -0,0 +1,52 @@ +options)) $route->setPrefix($this->options['prefix']); + $this->routes[] = [$route, $this->options]; + } + + public function find(string $name): Route + { + foreach ($this->routes as $route) { + if ($route[0]->getName() == $name) { + return $route[0]; + } + } + throw new RouteNotFoundException($name); + } + + public function findOption(string $name): array + { + foreach ($this->routes as $route) { + if ($route[0]->getName() == $name) { + return $route[1]; + } + } + } + + public function getRoutes(): array + { + return $this->routes; + } + + public function getLastRoute(): Route|false + { + if (count($this->routes) > 0) { + return end($this->routes)[0]; + } + return false; + } +} diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..9bbb775 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,173 @@ +middleware = new Middleware; + $this->collection = new RouteCollection; + $this->request = new Request; + } + + public function get(string $path, null|string|array|Closure $action) + { + $this->add(HttpMethod::GET, $path, $action); + return $this; + } + + public function post(string $path, null|string|array|Closure $action) + { + $this->add(HttpMethod::POST, $path, $action); + return $this; + } + + public function put(string $path, null|string|array|Closure $action) + { + $this->add(HttpMethod::PUT, $path, $action); + return $this; + } + + public function patch(string $path, null|string|array|Closure $action) + { + $this->add(HttpMethod::PATCH, $path, $action); + return $this; + } + + public function delete(string $path, null|string|array|Closure $action) + { + $this->add(HttpMethod::DELETE, $path, $action); + return $this; + } + + public function any(string $path, null|string|array|Closure $action) + { + $this + ->get($path, $action) + ->post($path, $action) + ->put($path, $action) + ->patch($path, $action) + ->delete($path, $action); + } + + private function add(HttpMethod $method, string $path, null|string|array|Closure $action) + { + $this->collection->add(new Route($method, $path, $action, null, null)); + } + + public function middleware(array $middlewares) + { + if ($route = $this->collection->getLastRoute()) { + $name = $route->getName(); + $this->middleware->add($name, $middlewares); + } + return $this; + } + + public function prefix(string $prefix) + { + if ($route = $this->collection->getLastRoute()) { + $route->setPrefix($prefix); + } + return $this; + } + + public function name(string $name) + { + if ($route = $this->collection->getLastRoute()) { + $route->setName($name); + } + return $this; + } + + public function group(Closure $closure, array $middlewares = [], ?string $prefix = '') + { + $this->collection->options = [ + 'groupIndex' => $this->middleware->currentGroupIndex, + 'prefix' => $prefix + ]; + $this->middleware->groupMiddlewares[$this->middleware->currentGroupIndex] = $middlewares; + $this->middleware->currentGroupIndex++; + $closure($this); + $this->collection->options = []; + } + + public function url(string $name, array $parameters = []) + { + $route = $this->collection->find($name); + return (new UrlGenerator($route))->generate($parameters); + } + + public function find(string $name) + { + return $this->collection->find($name); + } + + public function match() + { + return (new RouteMatcher($this->request, $this->getRouteCollection()))->match(); + } + + public function run() + { + $route = $this->match(); + + // Route middleware + if ($this->middleware->has($route->getName())) { + $middlewares = $this->middleware->get($route->getName()); + foreach ($middlewares as $middleware) { + if (!((new $middleware) instanceof Middleware) || !(new $middleware)->handle($this->request)) throw new MiddlewareException($middleware); + } + } + + // Group middleware + $option = $this->collection->findOption($route->getName()); + if (array_key_exists('groupIndex', $option)) { + foreach ($this->middleware->groupMiddlewares[$option['groupIndex']] as $middleware) { + if (!((new $middleware) instanceof Middleware) || !(new $middleware)->handle($this->request)) { + throw new MiddlewareException($middleware); + } + } + } + + return (new ActionManager)($route, $this->getRouteParameters($route)); + } + + public function getRouteCollection() + { + return $this->collection; + } + + public function getRouteParameters(Route $route) + { + $parameters = []; + $routePattern = $route->getPattern(); + $requestUri = $this->request->getUri(); + $routePattern = explode('/', $routePattern); + $requestUri = explode('/', $requestUri); + foreach ($routePattern as $key => $value) { + if (preg_match('/{(.*)}/', $value)) { + $parameters[] = $requestUri[$key]; + } + } + return $parameters; + } +} diff --git a/tests/Compiler/RoutePatternCompilerTest.php b/tests/Compiler/RoutePatternCompilerTest.php new file mode 100644 index 0000000..d82e063 --- /dev/null +++ b/tests/Compiler/RoutePatternCompilerTest.php @@ -0,0 +1,33 @@ +toRegex())->toBe('/^\/home$/'); +}); + +it('converts route with parameter without type to regex', function () { + $route = new Route(HttpMethod::GET, '/users/{id}', ''); + $compiler = new RoutePatternCompiler($route); + expect($compiler->toRegex())->toBe('/^\/users\/([^\/]+)$/'); +}); + +it('converts route with parameter with type to regex', function () { + $route = new Route(HttpMethod::GET, '/users/{id:int}', ''); + $compiler = new RoutePatternCompiler($route); + expect($compiler->toRegex())->toBe('/^\/users\/(\d+)$/'); +}); + +it('throws exception for invalid parameter type', function () { + $route = new Route(HttpMethod::GET, '/users/{id:invalid}', ''); + $compiler = new RoutePatternCompiler($route); + expect(function () use ($compiler) { + $compiler->toRegex(); + })->toThrow(InvalidParameterTypeException::class); +}); diff --git a/tests/Fixtures/Middleware/BarMiddleware.php b/tests/Fixtures/Middleware/BarMiddleware.php new file mode 100644 index 0000000..0c093a6 --- /dev/null +++ b/tests/Fixtures/Middleware/BarMiddleware.php @@ -0,0 +1,17 @@ +generate(['id' => '1', 'name' => 'foo']); + $urlRouteTwo = (new UrlGenerator($routeTwo))->generate(['id' => '1', 'name' => 'foo']); + $urlRouteThree = (new UrlGenerator($routeThree))->generate(['name' => 'foo', 'id' => '1']); + $urlRouteFour = (new UrlGenerator($routeFour))->generate(['id' => '1', 'name' => 'foo']); + $urlRouteFive = (new UrlGenerator($routeFive))->generate([]); + + expect($urlRouteOne)->toBe('/posts?id=1&name=foo'); + expect($urlRouteTwo)->toBe('/posts/1/category/foo'); + expect($urlRouteThree)->toBe('/posts/1/category/foo'); + expect($urlRouteFour)->toBe('/posts/1'); + expect($urlRouteFive)->toBe('/posts'); +}); diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php new file mode 100644 index 0000000..e670c58 --- /dev/null +++ b/tests/MiddlewareTest.php @@ -0,0 +1,43 @@ +middleware = new Middleware(); +}); + +test('can add and get middlewares', function () { + $this->middleware->add('auth', ['authMiddleware1', 'authMiddleware2']); + $this->middleware->add('admin', ['adminMiddleware1', 'adminMiddleware2']); + + $middlewares = $this->middleware->getMiddlewares(); + + expect($middlewares)->toHaveKey('auth'); + expect($middlewares)->toHaveKey('admin'); + expect($middlewares['auth'])->toBe(['authMiddleware1', 'authMiddleware2']); + expect($middlewares['admin'])->toBe(['adminMiddleware1', 'adminMiddleware2']); +}); + +test('can check if middleware exists', function () { + $this->middleware->add('auth', ['authMiddleware1', 'authMiddleware2']); + + expect($this->middleware->has('auth'))->toBeTrue(); + expect($this->middleware->has('admin'))->toBeFalse(); +}); + +test('can get middleware by name', function () { + $this->middleware->add('auth', ['authMiddleware1', 'authMiddleware2']); + + $middlewares = $this->middleware->get('auth'); + + expect($middlewares)->toBe(['authMiddleware1', 'authMiddleware2']); +}); + +test('can handle request', function () { + $request = new Request(); + + expect($this->middleware->handle($request))->toBeTrue(); +}); diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php new file mode 100644 index 0000000..7d844b9 --- /dev/null +++ b/tests/RouteCollectionTest.php @@ -0,0 +1,27 @@ +add($route); + + expect(count($routeCollection->getRoutes()))->toBe(1); +}); + +it('can find a route in the collection', function () { + $routeCollection = new RouteCollection(); + $route = new Route(HttpMethod::GET, '/', 'HomeController@index', 'home'); + $routeCollection->add($route); + + expect($routeCollection->find('home'))->toBe($route); +}); + +it('throws RouteNotFoundException when finding a nonexistent route', function () { + $routeCollection = new RouteCollection(); + $routeCollection->find('nonexistent'); +})->throws(RouteNotFoundException::class); + diff --git a/tests/RouteMatcherTest.php b/tests/RouteMatcherTest.php new file mode 100644 index 0000000..ecff8d0 --- /dev/null +++ b/tests/RouteMatcherTest.php @@ -0,0 +1,50 @@ +collection = new RouteCollection; + $this->collection->add(new Route(HttpMethod::GET, '/posts/{id}/category/{name:string}', '')); + $this->collection->add(new Route(HttpMethod::POST, '/users', '', 'user_create')); + $this->collection->add(new Route(HttpMethod::PUT, '/users', '', 'user_update')); + $this->collection->add(new Route(HttpMethod::DELETE, '/users', '', 'user_delete')); + $this->collection->add(new Route(HttpMethod::GET, '/', '')); +}); + +it('returns route if found multiple route ', function () { + $request = Request::create('/users', HttpMethod::PUT); + + $matcher = new RouteMatcher($request, $this->collection); + + $route = $matcher->match(); + + expect($route)->toBeInstanceOf(Route::class); + expect($route->getPattern())->toBe('/users'); + expect($route->method)->toBe(HttpMethod::PUT); +}); + +it('returns route if found', function () { + $request = Request::create('/posts/1/category/travels', HttpMethod::GET); + + $matcher = new RouteMatcher($request, $this->collection); + + $route = $matcher->match(); + + expect($route)->toBeInstanceOf(Route::class); + expect($route->getPattern())->toBe('/posts/{id}/category/{name:string}'); + expect($route->method)->toBe(HttpMethod::GET); +}); + +it('throws exception if no route found', function () { + $request = Request::create('/posts', HttpMethod::GET); + + $matcher = new RouteMatcher($request, $this->collection); + + expect(fn () => $matcher->match())->toThrow(RouteNotFoundException::class); +}); diff --git a/tests/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 0000000..4cb94aa --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,122 @@ +router = new Router; +}); + +it('Can add route', function () { + $this->router->get('/test', function () { + return 'Hello, World!'; + }); + + $routeCollection = $this->router->getRouteCollection(); + [$route, $option] = $routeCollection->getRoutes()[0]; + + expect($route)->toBeInstanceOf(Route::class); + expect($route->method)->toBe(HttpMethod::GET); + expect($route->getPattern())->toBe('/test'); + expect($route->action)->toBeInstanceOf(Closure::class); +}); + +it('Can match route', function () { + Request::create('/test', HttpMethod::GET); + + $this->router->get('/test', function () { + return 'Hello, World!'; + }); + + expect($this->router->run())->toBe('Hello, World!'); +}); + +it('Can find route by name', function () { + $this->router->get('/test', function () { + return 'Hello, World!'; + })->name('test_route'); + + $route = $this->router->find('test_route'); + + expect($route)->toBeInstanceOf(Route::class); + expect($route->method)->toBe(HttpMethod::GET); + expect($route->getPattern())->toBe('/test'); + expect($route->action)->toBeInstanceOf(Closure::class); +}); + +it('Can handle middleware', function () { + + Request::create('/foo', HttpMethod::GET); + + $this->router->get('/foo', [TestController::class, 'index'])->middleware([FooMiddleware::class]); + expect($this->router->run())->toBe('Hello, World!'); + + $this->router->get('/foo', [TestController::class, 'index'])->middleware([FooMiddleware::class, BazMiddleware::class]); + expect($this->router->run())->toBe('Hello, World!'); + + $this->router->get('/foo', [TestController::class, 'index'])->middleware([FooMiddleware::class, BarMiddleware::class]); + expect(fn () => $this->router->run())->toThrow(MiddlewareException::class); + + $this->router->get('/foo', [TestController::class, 'index'])->middleware([BarMiddleware::class]); + expect(fn () => $this->router->run())->toThrow(MiddlewareException::class); +}); + +it('Can group routes', function () { + + Request::create('/', HttpMethod::GET); + + $this->router->get('/', function () { + return 'Hello, World!'; + }); + + $this->router->group(function ($router) { + $router->get('/users', [TestController::class, 'index']); + $router->get('/users/{id}', [TestController::class, 'index']); + }, [BarMiddleware::class]); + + expect($this->router->run())->toBe('Hello, World!'); +}); + +it('Can set prefix on route', function () { + + $this->router->get('/users', function () { + return 'Hello, World!'; + })->prefix('/api/v1'); + + $route = $this->router->getRouteCollection()->getLastRoute(); + + expect($route->getPattern())->toBe('/api/v1/users'); +}); + +it('Can set name on route', function () { + + $this->router->get('/users', function () { + return 'Hello, World!'; + })->name('get_users'); + + $route = $this->router->getRouteCollection()->getLastRoute(); + + expect($route->getName())->toBe('get_users'); +}); + +it('Can generate url form route', function () { + + $this->router->get('/users/{id}', '')->name('get_user'); + $route = $this->router->getRouteCollection()->getLastRoute(); + $url = $this->router->url($route->getName(), ['id' => '1']); + expect($url)->toBe('/users/1'); + + // without name + $this->router->get('/posts/{slug}', ''); + $route = $this->router->getRouteCollection()->getLastRoute(); + $url = $this->router->url($route->getName(), ['slug' => 'test']); + expect($url)->toBe('/posts/test'); + + // wrong parameter + $url = $this->router->url($route->getName(), ['id' => '2']); + expect($url)->toBe('/posts/{slug}'); +});