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 0000000..c405ae8 Binary files /dev/null and b/art/routail.png differ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d0c0478 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "alirezasalehizadeh/routail", + "description": "A spidey PHP router.", + "keywords": ["php", "route", "router"], + "license": "MIT", + "authors": [ + { + "name": "Alireza Salehizadeh", + "email": "alirezasalehizadehco@gmail.com" + } + ], + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "pestphp/pest": "^2.6.3" + }, + "autoload": { + "psr-4": { + "AlirezaSalehizadeh\\Routail\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "AlirezaSalehizadeh\\Routail\\Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true, + "preferred-install": "dist", + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/src/Compiler/RoutePatternCompiler.php b/src/Compiler/RoutePatternCompiler.php new file mode 100644 index 0000000..2ab2d07 --- /dev/null +++ b/src/Compiler/RoutePatternCompiler.php @@ -0,0 +1,109 @@ + '([^/]+)', + '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}'); +});