├── .gitignore ├── src ├── Exception │ ├── RoutesFileException.php │ ├── LogicException.php │ ├── RuntimeException.php │ ├── InvalidArgumentException.php │ ├── UnexpectedValueException.php │ ├── RouteNotFoundException.php │ ├── BadRequestException.php │ ├── AjaxRouterException.php │ ├── MethodNotAllowedException.php │ └── HttpExceptionTrait.php ├── RouteResolver │ ├── ControllerMethod.php │ └── RouteResolver.php ├── Dispatcher.php ├── Psr7 │ └── Psr7RequestAdapter.php ├── Router.php └── Route.php ├── CHANGELOG.md ├── phpunit.xml ├── composer.json ├── LICENSE ├── tests └── Unit │ ├── DispatcherTest.php │ ├── RouteTest.php │ ├── RouterTest.php │ ├── RouteResolver │ └── RouteResolverTest.php │ └── Psr7 │ └── Psr7RequestAdapterTest.php ├── .github └── workflows │ └── tests.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .phpunit.result.cache -------------------------------------------------------------------------------- /src/Exception/RoutesFileException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | */ 12 | class RoutesFileException extends AjaxRouterException 13 | { 14 | 15 | } -------------------------------------------------------------------------------- /src/Exception/LogicException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | */ 12 | class LogicException extends AjaxRouterException 13 | { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | */ 12 | class RuntimeException extends AjaxRouterException 13 | { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | */ 12 | class InvalidArgumentException extends AjaxRouterException 13 | { 14 | 15 | } -------------------------------------------------------------------------------- /src/Exception/UnexpectedValueException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | */ 12 | class UnexpectedValueException extends AjaxRouterException 13 | { 14 | 15 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.0.6 (2022-03-31) 4 | 5 | * Allows getting the current route via `Route::getCurrentRoute`. 6 | 7 | ## 1.0.5 (2022-03-22) 8 | 9 | * Fixed resolving routes actions that defined as a functions. 10 | 11 | ## 1.0.4 (2022-03-13) 12 | 13 | * Fixed compatibility issues with PHP ^8.0 versions. 14 | 15 | ## 1.0.2 (2022-03-04) 16 | 17 | * Integrated Lazzard\Psr7ResponseSender. 18 | 19 | ## 1.0.0 (2022-02-14) 20 | 21 | * First stable release. 22 | -------------------------------------------------------------------------------- /src/Exception/RouteNotFoundException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | * @internal 12 | */ 13 | class RouteNotFoundException extends AjaxRouterException 14 | { 15 | public function __construct($message, $code = 400) 16 | { 17 | parent::__construct($message, $code); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exception/BadRequestException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | * @internal 12 | */ 13 | class BadRequestException extends AjaxRouterException 14 | { 15 | public function __construct($message, $code = 400) 16 | { 17 | parent::__construct($message, $code); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | src/ 9 | 10 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Exception/AjaxRouterException.php: -------------------------------------------------------------------------------- 1 | 12 | * @link https://amranich.dev 13 | */ 14 | class AjaxRouterException extends \Exception 15 | { 16 | use HttpExceptionTrait { 17 | HttpExceptionTrait::__construct as private __HttpExceptionTraitConstructor; 18 | } 19 | 20 | public function __construct($message, $code = 500, $headers = []) 21 | { 22 | parent::__construct($message, $code); 23 | $this->__HttpExceptionTraitConstructor($message, $code, $headers); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Exception/MethodNotAllowedException.php: -------------------------------------------------------------------------------- 1 | 10 | * @link https://amranich.dev 11 | * @internal 12 | */ 13 | class MethodNotAllowedException extends AjaxRouterException 14 | { 15 | 16 | public function __construct($message, $code = 405, $allowedMethods = []) 17 | { 18 | parent::__construct($message, $code, [ 19 | "Allow" => $this->formatAllowHeaderValue($allowedMethods) 20 | ]); 21 | } 22 | 23 | /** 24 | * @param array $methods 25 | * @return string 26 | */ 27 | protected function formatAllowHeaderValue(array $methods) 28 | { 29 | if (empty($methods)) { 30 | return ''; 31 | } 32 | 33 | return implode(', ', $methods); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exception/HttpExceptionTrait.php: -------------------------------------------------------------------------------- 1 | 14 | * @link https://amranich.dev 15 | */ 16 | trait HttpExceptionTrait 17 | { 18 | public function __construct($message, $code, $headers = []) 19 | { 20 | // check if the headers was already sent 21 | if (!empty($headers) && !headers_sent()) { 22 | http_response_code($code); 23 | 24 | foreach($headers as $name => $value) { 25 | header("$name: $value"); 26 | } 27 | } 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amranich/ajax-router", 3 | "description": "Handle your AJAX requests efficiently", 4 | "keywords": [ 5 | "dispatcher", 6 | "router", 7 | "ajax-dispatcher", 8 | "dipatcher-route", 9 | "ajax-request" 10 | ], 11 | "type": "library", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "El Amrani Chakir", 16 | "email": "contact@amranich.dev", 17 | "homepage": "https://amranich.dev" 18 | } 19 | ], 20 | "minimum-stability": "stable", 21 | "require": { 22 | "php": "^5.6 || ^7.0 || ^8.0", 23 | "psr/http-message": "^1.0", 24 | "lazzard/psr7-response-sender": "^1.0" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^9", 28 | "guzzlehttp/psr7": "^2.1" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "AmraniCh\\AjaxRouter\\": "src/" 33 | } 34 | }, 35 | "config": { 36 | "platform": { 37 | "php": "7.4" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 El Amrani Chakir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/Unit/DispatcherTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Router::class) 14 | ->disableOriginalConstructor() 15 | ->getMock(); 16 | 17 | $dispatcherMock = $this->getMockBuilder(Dispatcher::class) 18 | ->disableOriginalConstructor() 19 | ->onlyMethods([]) 20 | ->getMock(); 21 | 22 | $this->assertSame($dispatcherMock, $dispatcherMock->setRouter($routerMock)); 23 | $this->assertSame($routerMock, $dispatcherMock->getRouter()); 24 | } 25 | 26 | public function test_onException(): void 27 | { 28 | $dispatcherMock = $this->getMockBuilder(Dispatcher::class) 29 | ->disableOriginalConstructor() 30 | ->onlyMethods([]) 31 | ->getMock(); 32 | 33 | $onException = function() {}; 34 | 35 | $this->assertSame($dispatcherMock, $dispatcherMock->onException($onException)); 36 | $this->assertSame($onException, $dispatcherMock->getOnExceptionCallback()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "master" 8 | 9 | jobs: 10 | build: 11 | name: PHP ${{ matrix.php-versions }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php-versions: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] 17 | 18 | steps: 19 | - name: Setup PHP Action 20 | uses: shivammathur/setup-php@1.8.2 21 | with: 22 | extensions: intl 23 | php-version: "${{ matrix.php-versions }}" 24 | coverage: pcov 25 | 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Validate composer.json 30 | run: "composer validate" 31 | 32 | - name: Install dependencies 33 | run: "composer install --prefer-dist --no-progress --no-suggest" 34 | 35 | - name: Run test suite 36 | run: "vendor/bin/phpunit --coverage-clover=coverage.xml" 37 | if: ${{ matrix.php >= 7.1 }} 38 | 39 | - name: Run test suite for PHP versions < 7.1 40 | run: "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover=coverage.xml" 41 | if: ${{ matrix.php < 7.1 }} 42 | 43 | - name: Upload coverage to Codecov 44 | uses: codecov/codecov-action@v1 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | file: ./coverage.xml 48 | flags: tests 49 | name: codecov-umbrella 50 | yml: ./codecov.yml 51 | fail_ci_if_error: true -------------------------------------------------------------------------------- /src/RouteResolver/ControllerMethod.php: -------------------------------------------------------------------------------- 1 | 12 | * @link https://amranich.dev 13 | * @internal 14 | */ 15 | class ControllerMethod 16 | { 17 | /** @var string|object */ 18 | protected $class; 19 | 20 | /** @var string */ 21 | protected $name; 22 | 23 | /** 24 | * @param string|object $class 25 | * @param string $name 26 | */ 27 | public function __construct($class, $name) 28 | { 29 | $this->setClass($class); 30 | $this->setName($name); 31 | } 32 | 33 | /** 34 | * @return object|string 35 | */ 36 | public function getClass() 37 | { 38 | return $this->class; 39 | } 40 | 41 | /** 42 | * @param object|string $class 43 | * 44 | * @return ControllerMethod 45 | */ 46 | public function setClass($class) 47 | { 48 | $this->class = $class; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * @return string 55 | */ 56 | public function getName() 57 | { 58 | return $this->name; 59 | } 60 | 61 | /** 62 | * @param string $name 63 | * 64 | * @return ControllerMethod 65 | */ 66 | public function setName($name) 67 | { 68 | $this->name = $name; 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * Calls and executes the controller method. 75 | * 76 | * @param array $args 77 | * 78 | * @return mixed 79 | * @throws LogicException 80 | */ 81 | public function call(array $args) 82 | { 83 | if (is_object($this->class) === false && class_exists($this->class) === false) { 84 | throw new LogicException("Controller class '$this->class' not found."); 85 | } 86 | 87 | if (!method_exists($this->class, $this->name)) { 88 | throw new LogicException(sprintf( 89 | "The method '%s' not exist in controller '%s'.", 90 | $this->name, 91 | is_object($this->class) ? get_class($this->class) : $this->class 92 | ) 93 | ); 94 | } 95 | 96 | $callable = [ 97 | is_string($this->class) ? new $this->class() : $this->class, 98 | $this->name 99 | ]; 100 | 101 | return call_user_func_array($callable, $args); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Dispatcher.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class Dispatcher 18 | { 19 | /** @var Router */ 20 | protected $router; 21 | 22 | /** @var callable */ 23 | protected $onExceptionCallback; 24 | 25 | /** 26 | * @param Router $router 27 | */ 28 | public function __construct(Router $router) 29 | { 30 | $this->setRouter($router); 31 | } 32 | 33 | /** 34 | * @return Router 35 | */ 36 | public function getRouter() 37 | { 38 | return $this->router; 39 | } 40 | 41 | /** 42 | * @return callable 43 | */ 44 | public function getOnExceptionCallback() 45 | { 46 | return $this->onExceptionCallback; 47 | } 48 | 49 | /** 50 | * @param Router $router 51 | * 52 | * @return Dispatcher 53 | */ 54 | public function setRouter(Router $router) 55 | { 56 | $this->router = $router; 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * Executes the route action and generate the response. 63 | * 64 | * @return Dispatcher 65 | * @throws \Exception 66 | */ 67 | public function dispatch() 68 | { 69 | $handler = $this->router->run(); 70 | 71 | $response = $this->handleException($handler); 72 | 73 | if ($response instanceof ResponseInterface) { 74 | $sender = new Sender; 75 | $sender($response); 76 | } 77 | 78 | if (is_string($response)) { 79 | echo $response; 80 | } 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * Allows using a custom exception handler for exceptions that may throw when calling the route actions. 87 | * 88 | * @param callable $callable a callback function that will accept the exception as a first argument. 89 | * 90 | * @return Dispatcher 91 | */ 92 | public function onException(callable $callable) 93 | { 94 | $this->onExceptionCallback = $callable; 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * handle exceptions that may be thrown during the callback call. 101 | * 102 | * @param \Closure $callback 103 | * 104 | * @return mixed 105 | * @throws \Exception 106 | */ 107 | protected function handleException(\Closure $callback) 108 | { 109 | if (!is_callable($this->onExceptionCallback)) { 110 | return $callback(); 111 | } 112 | 113 | try { 114 | return $callback(); 115 | } catch (\Exception $ex) { 116 | return call_user_func($this->onExceptionCallback, $ex); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Psr7/Psr7RequestAdapter.php: -------------------------------------------------------------------------------- 1 | 12 | * @link https://amranich.dev 13 | * @internal 14 | */ 15 | class Psr7RequestAdapter 16 | { 17 | /** @var ServerRequestInterface */ 18 | protected $request; 19 | 20 | /** 21 | * @param ServerRequestInterface $request 22 | */ 23 | public function __construct(ServerRequestInterface $request) 24 | { 25 | $this->request = $request; 26 | } 27 | 28 | /** 29 | * Gets request variables. 30 | * 31 | * @return array 32 | */ 33 | public function getVariables() 34 | { 35 | if ($this->request->getMethod() === 'GET') { 36 | return $this->getUriQueryVariables(); 37 | } 38 | 39 | return array_merge($this->getBodyVariables(), $this->getUriQueryVariables()); 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | protected function getUriQueryVariables() 46 | { 47 | if ($this->isJsonContentType()) { 48 | // because of the content passed in the URL we need to decode it 49 | $query = urldecode($this->request->getUri()->getQuery()); 50 | return json_decode($query, true) ?: []; 51 | } 52 | 53 | return $this->request->getQueryParams(); 54 | } 55 | 56 | /** 57 | * @return array 58 | */ 59 | protected function getBodyVariables() 60 | { 61 | if ($this->isJsonContentType()) { 62 | return json_decode($this->request->getBody()->getContents(), true); 63 | } 64 | 65 | if ($this->isUrlencodedContentType()) { 66 | if (($body = $this->request->getParsedBody()) === '') { 67 | return []; 68 | } 69 | 70 | return $body; 71 | } 72 | 73 | if ($this->isFormDataContentType()) { 74 | return array_merge( 75 | $this->request->getParsedBody(), 76 | $this->request->getUploadedFiles() 77 | ); 78 | } 79 | 80 | // TODO should throw an exception if response content type is not present 81 | // or it value is invalid ? 82 | 83 | return []; 84 | } 85 | 86 | /** 87 | * Checks if the request content type is JSON type. 88 | * 89 | * @return bool 90 | */ 91 | protected function isJsonContentType() 92 | { 93 | return $this->isHeaderContains( 94 | 'Content-Type', 95 | 'application/json' 96 | ); 97 | } 98 | 99 | /** 100 | * @return bool 101 | */ 102 | protected function isUrlencodedContentType() 103 | { 104 | return $this->isHeaderContains( 105 | 'Content-Type', 106 | 'application/x-www-form-urlencoded' 107 | ); 108 | } 109 | 110 | /** 111 | * @return bool 112 | */ 113 | protected function isFormDataContentType() 114 | { 115 | return $this->isHeaderContains( 116 | 'Content-Type', 117 | 'multipart/form-data' 118 | ); 119 | } 120 | 121 | /** 122 | * @return bool 123 | */ 124 | protected function isHeaderContains($header, $value) 125 | { 126 | if (!$this->request->hasHeader($header)) { 127 | return false; 128 | } 129 | 130 | return strpos($this->request->getHeaderLine($header), $value) !== false; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/RouteResolver/RouteResolver.php: -------------------------------------------------------------------------------- 1 | 17 | * @link https://amranich.dev 18 | */ 19 | class RouteResolver 20 | { 21 | /** @var ServerRequestInterface */ 22 | protected $request; 23 | 24 | /** @var array */ 25 | protected $variables; 26 | 27 | /** @var array */ 28 | protected $controllers; 29 | 30 | /** 31 | * @param ServerRequestInterface $request 32 | * @param array $variables 33 | * @param array $controllers 34 | */ 35 | public function __construct(ServerRequestInterface $request, array $variables, array $controllers) 36 | { 37 | $this->request = $request; 38 | $this->variables = $variables; 39 | $this->controllers = $controllers; 40 | } 41 | 42 | /** 43 | * @param Route $route 44 | * 45 | * @return \Closure 46 | * @throws UnexpectedValueException|LogicException 47 | */ 48 | public function resolve(Route $route) 49 | { 50 | $value = $route->getValue(); 51 | 52 | if (is_string($value)) { 53 | return $this->resolveString($value); 54 | } 55 | 56 | if (is_array($value)) { 57 | return $this->resolveArray($value); 58 | } 59 | 60 | if ($value instanceof \Closure) { 61 | return $this->resolveFunction($value); 62 | } 63 | 64 | throw new UnexpectedValueException(sprintf( 65 | "Unexpected handler value, expecting string/array/function '%s' given.", 66 | gettype($route) 67 | )); 68 | } 69 | 70 | /** 71 | * Resolves routes values that defined as a string. 72 | * 73 | * @param string $string 74 | * 75 | * @return \Closure 76 | * @throws LogicException 77 | */ 78 | protected function resolveString($string) 79 | { 80 | $tokens = @explode('@', $string); 81 | 82 | $controller = $tokens[0]; 83 | $method = $tokens[1]; 84 | 85 | $registeredController = $this->getRegisteredControllerByName($controller); 86 | 87 | if (!$registeredController) { 88 | throw new LogicException("Controller class '$controller' not registered."); 89 | } 90 | 91 | $method = $this->getCallableMethod($registeredController, $method); 92 | 93 | return function () use ($method) { 94 | return call_user_func($method, [$this->variables, $this->request]); 95 | }; 96 | } 97 | 98 | /** 99 | * Resolve routes actions that defined as an array of class and method name. 100 | * 101 | * @param array $array 102 | * 103 | * @return \Closure 104 | */ 105 | protected function resolveArray(array $array) 106 | { 107 | $method = $this->getCallableMethod($array[0], $array[1]); 108 | return function () use ($method) { 109 | return call_user_func($method, [$this->variables, $this->request]); 110 | }; 111 | } 112 | 113 | /** 114 | * Resolve routes actions that defined a functions. 115 | * 116 | * @param array $array 117 | * 118 | * @return \Closure 119 | */ 120 | protected function resolveFunction($function) 121 | { 122 | return function () use ($function) { 123 | return call_user_func($function, $this->variables, $this->request); 124 | }; 125 | } 126 | 127 | /** 128 | * @param string $controller 129 | * @param string $method 130 | * 131 | * @return \Closure 132 | */ 133 | protected function getCallableMethod($controller, $method) 134 | { 135 | $method = new ControllerMethod($controller, $method); 136 | 137 | return function ($args = []) use ($method) { 138 | return $method->call($args); 139 | }; 140 | } 141 | 142 | /** 143 | * Gets controller instance from the registered controllers using it name. 144 | * 145 | * @param string $name 146 | * 147 | * @return string|null 148 | */ 149 | protected function getRegisteredControllerByName($name) 150 | { 151 | foreach ($this->controllers as $controller) { 152 | if ($this->getControllerName($controller) === $name) { 153 | return $controller; 154 | } 155 | } 156 | 157 | return null; 158 | } 159 | 160 | /** 161 | * Gets controller name. 162 | * 163 | * @param object|string $controller 164 | * 165 | * @return string|null 166 | */ 167 | protected function getControllerName($controller) 168 | { 169 | $path = null; 170 | 171 | if (is_object($controller)) { 172 | $class = get_class($controller); 173 | $path = explode('\\', $class); 174 | } 175 | 176 | if (is_string($controller)) { 177 | $path = explode('\\', $controller); 178 | } 179 | 180 | return !$path ?: array_pop($path); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /tests/Unit/RouteTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Route::class) 16 | ->disableOriginalConstructor() 17 | ->onlyMethods([]) 18 | ->getMock(); 19 | 20 | $methods = ['GET', 'POST']; 21 | 22 | $this->assertSame($routeMock, $routeMock->setMethods($methods)); 23 | $this->assertSame($methods, $routeMock->getMethods()); 24 | } 25 | 26 | public function test_setMethods_with_unsupported_method(): void 27 | { 28 | $routeMock = $this->getMockBuilder(Route::class) 29 | ->disableOriginalConstructor() 30 | ->onlyMethods([]) 31 | ->getMock(); 32 | 33 | $methods = ['GET', 'REMOVE', 'PUT']; 34 | 35 | $this->expectException(InvalidArgumentException::class); 36 | $this->expectExceptionCode(500); 37 | 38 | $routeMock->setMethods($methods); 39 | } 40 | 41 | public function test_getMethods(): void 42 | { 43 | $routeMock = $this->getMockBuilder(Route::class) 44 | ->disableOriginalConstructor() 45 | ->onlyMethods([]) 46 | ->getMock(); 47 | 48 | $methods = ['GET', 'POST']; 49 | 50 | $routeMock->setMethods($methods); 51 | 52 | $this->assertSame($methods, $routeMock->getMethods()); 53 | } 54 | 55 | public function test_setName(): void 56 | { 57 | $routeMock = $this->getMockBuilder(Route::class) 58 | ->disableOriginalConstructor() 59 | ->onlyMethods([]) 60 | ->getMock(); 61 | 62 | $routeMock->setName('getComments'); 63 | 64 | $this->assertSame('getComments', $routeMock->getName()); 65 | } 66 | 67 | public function test_setName_with_invalid_argument_type(): void 68 | { 69 | $routeMock = $this->getMockBuilder(Route::class) 70 | ->disableOriginalConstructor() 71 | ->onlyMethods([]) 72 | ->getMock(); 73 | 74 | $this->expectException(InvalidArgumentException::class); 75 | $this->expectExceptionCode(500); 76 | 77 | $routeMock->setName(['getComments']); 78 | } 79 | 80 | public function test_setValue_with_string_value(): void 81 | { 82 | $routeMock = $this->getMockBuilder(Route::class) 83 | ->disableOriginalConstructor() 84 | ->onlyMethods([]) 85 | ->getMock(); 86 | 87 | $this->assertSame($routeMock, $routeMock->setValue('CommmentController@addComment')); 88 | $this->assertSame('CommmentController@addComment', $routeMock->getValue()); 89 | } 90 | 91 | public function test_setValue_with_callable_value(): void 92 | { 93 | $routeMock = $this->getMockBuilder(Route::class) 94 | ->disableOriginalConstructor() 95 | ->onlyMethods([]) 96 | ->getMock(); 97 | 98 | $this->assertSame($routeMock, $routeMock->setValue(function () { 99 | })); 100 | $this->assertInstanceOf(\Closure::class, $routeMock->getValue()); 101 | } 102 | 103 | public function test_setValue_with_invalid_value_argument_type(): void 104 | { 105 | $routeMock = $this->getMockBuilder(Route::class) 106 | ->disableOriginalConstructor() 107 | ->onlyMethods([]) 108 | ->getMock(); 109 | 110 | $this->expectException(InvalidArgumentException::class); 111 | $this->expectExceptionCode(500); 112 | 113 | $routeMock->setValue(new \stdClass); 114 | } 115 | 116 | public function test_get_static(): void 117 | { 118 | $methods = ['GET']; 119 | $name = 'getPosts'; 120 | $value = 'PostController@getPosts'; 121 | 122 | $route = Route::get($name, $value); 123 | 124 | $this->assertSame($methods, $route->getMethods()); 125 | $this->assertSame($name, $route->getName()); 126 | $this->assertSame($name, $route->getName()); 127 | } 128 | 129 | public function test_post_static(): void 130 | { 131 | $methods = ['POST']; 132 | $name = 'userLogin'; 133 | $value = 'UserController@login'; 134 | 135 | $route = Route::post($name, $value); 136 | 137 | $this->assertSame($methods, $route->getMethods()); 138 | $this->assertSame($name, $route->getName()); 139 | $this->assertSame($name, $route->getName()); 140 | } 141 | 142 | public function test_many_static(): void 143 | { 144 | $methods = ['GET', 'POST']; 145 | $name = 'getUser'; 146 | $value = 'UserController@getUserByID'; 147 | 148 | $route = Route::many($methods, $name, $value); 149 | 150 | $this->assertSame($methods, $route->getMethods()); 151 | $this->assertSame($name, $route->getName()); 152 | $this->assertSame($name, $route->getName()); 153 | } 154 | 155 | public function test_setCurrentRoute(): void 156 | { 157 | $route = $this->createMock(Route::class); 158 | 159 | $this->assertNull(Route::setCurrentRoute($route)); 160 | $this->assertSame($route, Route::getCurrentRoute($route)); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![packagist](https://img.shields.io/packagist/v/AmraniCh/ajax-router?include_prereleases)](https://packagist.org/packages/amranich/ajax-router) 2 | [![tests](https://github.com/AmraniCh/ajax-dispatcher/actions/workflows/tests.yml/badge.svg)](https://github.com/AmraniCh/ajax-dispatcher/actions/workflows/tests.yml) 3 | ![License](https://img.shields.io/packagist/l/AmraniCh/ajax-router) 4 | 5 | 6 | ## Getting Started 7 | 8 | ```bash 9 | composer require amranich/ajax-router 10 | ``` 11 | 12 | You can copy/paste this code snippet for a quick start. 13 | 14 | We're using [Guzzle PSR-7 interface implementation](https://github.com/guzzle/psr7) here, but you can use any other library you like as long as it implements the same interface. 15 | 16 | ```php 17 | hasHeader('X-requested-with') 38 | || strtolower($request->getHeader('X-requested-with')[0]) !== 'XMLHttpRequest') { 39 | throw new BadRequestException("Accept only AJAX requests."); 40 | } 41 | 42 | // to organize your project, you can put your routes in a separate file like in an array 43 | // and require it in the second parameter of the router constructor. 44 | $router = new Router($request, 'route', [ 45 | 46 | // ?route=getPost&id=1005 47 | Route::get('getPost', function ($params) { 48 | 49 | // PSR7 responses are a little annoying to work with, you always have extra HTTP layers 50 | // in your app that extend the base PSR7 response class, think of a class like JsonResponse, 51 | // and in the constructor add the content-type header and pass it to the parent class. 52 | $response = new Response; 53 | 54 | $response->getBody()->write(json_encode([ 55 | 'id' => $params['id'], 56 | 'title' => 'Best Places to Visit in Marrakech', 57 | 'description' => 'Example of post description', 58 | 'created_at' => '2022-02-27 03:00:05' 59 | ])); 60 | 61 | return $response->withHeader('Content-type', 'application/json'); 62 | }), 63 | ]); 64 | 65 | $dispatcher = new Dispatcher($router); 66 | $dispatcher->dispatch(); 67 | 68 | } catch (Exception $ex) { 69 | $response = new Response( 70 | $ex->getCode() ?: 500, 71 | ['Content-type' => 'application/json'], 72 | json_encode(['message' => $ex->getMessage()]) 73 | ); 74 | 75 | $sender = new Sender; 76 | $sender($response); 77 | } 78 | ``` 79 | 80 | ## Usage Tips 81 | 82 | ### Route to controller/class method 83 | 84 | If you like to put the business logic in a separate class or in a controller, you can route your requests to them like this : 85 | 86 | ```php 87 | Route::get('getPost', [PostController::class, 'getPost']); 88 | ``` 89 | 90 | Or : 91 | 92 | ```php 93 | Route::get('getPost', 'PostController@getPost'); 94 | 95 | // register the controller class or instance in the router 96 | $router->registerControllers([ 97 | PostController::class, 98 | ]); 99 | ``` 100 | 101 | If the controller/class has some dependencies that must be passed within the constructor, you can still instantiate the 102 | controller on yourself : 103 | 104 | ```php 105 | $router->registerControllers([ 106 | new PostController($dependencyOne, $dependencyTwo) 107 | ]); 108 | ``` 109 | 110 | ### Catch route actions exceptions 111 | 112 | *I want to catch exceptions that only occurs from my routes actions, and not those thrown by the library or somewhere else, how I can 113 | do that ?* 114 | 115 | Answer : 116 | 117 | ```php 118 | $dispatcher->onException(function (\Exception $ex) { 119 | // $ex exception thrown by a route action 120 | }); 121 | ``` 122 | 123 | ### Get current route 124 | 125 | You can access the current route object using the static method `getCurrentRoute` of the `Route` class. 126 | 127 | ```php 128 | $route = Router::getCurrentRoute(); 129 | $route->getName(); 130 | $route->getMethods(); 131 | $route->getValue(); 132 | 133 | ``` 134 | 135 | ## Background 136 | 137 | The idea of the library came to my mind a long time ago when I was mostly developing web applications using just plain PHP, some of these applications were performing a lot of AJAX requests into a single PHP file, that file can have a hundred lines of code that process these requests depending on a function/method name that sent along with the request, so I started to think of what I can do to improve the way that these requests are handled, and improve the code readability and maintainability. 138 | 139 | ## Prizes 140 | 141 | This package wins the PHP Innovation Award (February 2022) Issued by phpclasses.org 142 | 143 | 🏆 Prize : 144 | One elePHPant mascot. 145 | https://www.php.net/elephpant.php 146 | 147 | 📜 Certificate : 148 | https://amranich.dev/certs/phpclasses-february-2022-innovation-award.pdf 149 | 150 | ## They support this project 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | 22 | * @link https://amranich.dev 23 | */ 24 | class Router 25 | { 26 | /** @var ServerRequestInterface */ 27 | protected $request; 28 | 29 | /** @var string */ 30 | protected $routeVariable; 31 | 32 | /** @var array */ 33 | protected $routes; 34 | 35 | /** @var array */ 36 | protected $controllers = []; 37 | 38 | /** 39 | * @param ServerRequestInterface $request A request class that implement the {@see ServerRequestInterface} 40 | * interface. 41 | * @param string $handlerName The route name to be executed. 42 | * @param array $routes 43 | * @param array $controllers 44 | * 45 | * @throws InvalidArgumentException 46 | */ 47 | public function __construct(ServerRequestInterface $request, $handlerName, array $routes, array $controllers = []) 48 | { 49 | $this->setRequest($request); 50 | $this->setRouteVariable($handlerName); 51 | $this->setRoutes($routes); 52 | $this->registerControllers($controllers); 53 | } 54 | 55 | /** 56 | * @return ServerRequestInterface 57 | */ 58 | public function getRequest() 59 | { 60 | return $this->request; 61 | } 62 | 63 | /** 64 | * @return string 65 | */ 66 | public function getRouteVariable() 67 | { 68 | return $this->routeVariable; 69 | } 70 | 71 | /** 72 | * @return array 73 | */ 74 | public function getRoutes() 75 | { 76 | return $this->routes; 77 | } 78 | 79 | /** 80 | * Gets registered controllers namespaces. 81 | * 82 | * @return array 83 | */ 84 | public function getControllers() 85 | { 86 | return $this->controllers; 87 | } 88 | 89 | /** 90 | * @param ServerRequestInterface $request 91 | * 92 | * @return Router 93 | */ 94 | public function setRequest(ServerRequestInterface $request) 95 | { 96 | $this->request = $request; 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * @param string $routeVariable 103 | * 104 | * @return Router 105 | * @throws InvalidArgumentException 106 | */ 107 | public function setRouteVariable($routeVariable) 108 | { 109 | if (!is_string($routeVariable)) { 110 | throw new InvalidArgumentException(sprintf( 111 | "A route variable must be of type string, '%s' type given.", 112 | gettype($routeVariable) 113 | )); 114 | } 115 | 116 | $this->routeVariable = $routeVariable; 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * @param array $routes 123 | * 124 | * @return Router 125 | */ 126 | public function setRoutes($routes) 127 | { 128 | $this->routes = $routes; 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Register controllers namespaces/instances. 135 | * 136 | * @param array $controllers An array of controller instances or namespaces. 137 | * 138 | * @return Router 139 | */ 140 | public function registerControllers(array $controllers) 141 | { 142 | $this->controllers = $controllers; 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * Runs the logic of find the proper handler for the request. 149 | * 150 | * @return \Closure 151 | * @throws BadRequestException|RouteNotFoundException|MethodNotAllowedException|UnexpectedValueException|LogicException 152 | * @internal 153 | */ 154 | public function run() 155 | { 156 | $request = new Psr7RequestAdapter($this->request); 157 | $variables = $request->getVariables(); 158 | 159 | if (!array_key_exists($this->routeVariable, $variables)) { 160 | throw new BadRequestException(sprintf( 161 | "The route parameter '%s' not found in request variables.", 162 | $this->routeVariable 163 | )); 164 | } 165 | 166 | $routeName = $variables[$this->routeVariable]; 167 | 168 | if ($routeName === '') { 169 | throw new BadRequestException("Route name not given."); 170 | } 171 | 172 | foreach ($this->getRoutes() as $route) { 173 | if ($route->getName() !== $routeName) { 174 | continue; 175 | } 176 | 177 | if (!in_array($this->request->getMethod(), $route->getMethods())) { 178 | throw new MethodNotAllowedException(sprintf( 179 | "The handler '%s' is registered for another HTTP request method(s) [%s].", 180 | $route->getName(), 181 | implode(', ', $route->getMethods()) 182 | ), 405, $route->getMethods()); 183 | } 184 | 185 | Route::setCurrentRoute($route); 186 | 187 | $resolver = new RouteResolver( 188 | $this->getRequest(), 189 | $variables, 190 | $this->getControllers() 191 | ); 192 | 193 | return $resolver->resolve($route); 194 | } 195 | 196 | throw new RouteNotFoundException('No Route found for this request.'); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | 13 | * @link https://amranich.dev 14 | */ 15 | class Route 16 | { 17 | /** @var Route */ 18 | protected static $currentRoute; 19 | 20 | /** @var array */ 21 | protected $methods; 22 | 23 | /** @var string */ 24 | protected $name; 25 | 26 | /** @var string|callable */ 27 | protected $value; 28 | 29 | /** 30 | * HTTP methods supported by a route. 31 | * 32 | * @var array 33 | */ 34 | const SUPPORTED_METHODS = [ 35 | 'GET', 36 | 'POST', 37 | ]; 38 | 39 | /** 40 | * @param array $methods 41 | * @param string $name 42 | * @param string|callable $value 43 | * 44 | * @throws InvalidArgumentException 45 | */ 46 | public function __construct($methods, $name, $value) 47 | { 48 | $this->setMethods($methods); 49 | $this->setName($name); 50 | $this->setValue($value); 51 | } 52 | 53 | /** 54 | * Gets route methods. 55 | * 56 | * @return array 57 | */ 58 | public function getMethods() 59 | { 60 | return $this->methods; 61 | } 62 | 63 | /** 64 | * Gets route name. 65 | * 66 | * @return string 67 | */ 68 | public function getName() 69 | { 70 | return $this->name; 71 | } 72 | 73 | /** 74 | * Gets route value. 75 | * 76 | * @return string|callable 77 | */ 78 | public function getValue() 79 | { 80 | return $this->value; 81 | } 82 | 83 | /** 84 | * Set the value of method 85 | * 86 | * @param array $methods 87 | * 88 | * @return Route 89 | * @throws InvalidArgumentException 90 | */ 91 | public function setMethods(array $methods) 92 | { 93 | foreach ($methods as $method) { 94 | if (!in_array($method, self::SUPPORTED_METHODS)) { 95 | throw new InvalidArgumentException(sprintf( 96 | "'%s' HTTP request method not supported by a route, the supported methods are [%s].", 97 | $method, 98 | implode(', ', self::SUPPORTED_METHODS) 99 | )); 100 | } 101 | } 102 | 103 | $this->methods = $methods; 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Set the value of name 110 | * 111 | * @return Route 112 | * 113 | * @throws InvalidArgumentException 114 | */ 115 | public function setName($name) 116 | { 117 | if (!is_string($name)) { 118 | throw new InvalidArgumentException("The route name must be of type string."); 119 | } 120 | 121 | $this->name = $name; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Set the value of value 128 | * 129 | * @return Route 130 | * 131 | * @throws InvalidArgumentException 132 | */ 133 | public function setValue($value) 134 | { 135 | if (!is_string($value) && !is_array($value) && !is_callable($value)) { 136 | throw new InvalidArgumentException(sprintf( 137 | "A route value type must be either a string/array, giving '%s' for route with name '%s'", 138 | gettype($value), 139 | $this->getName() 140 | )); 141 | } 142 | 143 | $this->value = $value; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * Define routes from the giving file content. 150 | * 151 | * @param string $path 152 | * 153 | * @return array 154 | * @throws RoutesFileException 155 | */ 156 | public static function fromFile($path) 157 | { 158 | if (!file_exists($path)) { 159 | throw new RoutesFileException("The routes file ($path) not exists."); 160 | } 161 | 162 | if (!is_readable($path)) { 163 | throw new RoutesFileException("The routes file ($path) is not readable, please check the file permissions."); 164 | } 165 | 166 | $content = include($path); 167 | 168 | if (!is_array($content)) { 169 | throw new RoutesFileException(sprintf( 170 | "The routes file (%s) must return an array of routes objects, '%s' value returned.", 171 | $path, 172 | gettype($content) 173 | )); 174 | } 175 | 176 | return $content; 177 | } 178 | 179 | /** 180 | * @param Route $route 181 | * 182 | * @return void 183 | */ 184 | public static function setCurrentRoute(Route $route) 185 | { 186 | self::$currentRoute = $route; 187 | } 188 | 189 | /** 190 | * @return Route 191 | */ 192 | public static function getCurrentRoute() 193 | { 194 | return static::$currentRoute; 195 | } 196 | 197 | /** 198 | * Creates route with GET request method. 199 | * 200 | * @param string $name 201 | * @param string|callable $value 202 | * 203 | * @return Route 204 | * @throws InvalidArgumentException 205 | */ 206 | public static function get($name, $value) 207 | { 208 | return static::getInstance(['GET'], $name, $value); 209 | } 210 | 211 | /** 212 | * Creates route with POST request method. 213 | * 214 | * @param string $name 215 | * @param string|callable $value 216 | * 217 | * @return Route 218 | * @throws InvalidArgumentException 219 | */ 220 | public static function post($name, $value) 221 | { 222 | return static::getInstance(['POST'], $name, $value); 223 | } 224 | 225 | /** 226 | * Creates route with multiple request methods. 227 | * 228 | * @param array $methods 229 | * @param string $name 230 | * @param string|callable $value 231 | * 232 | * @return Route 233 | * @throws InvalidArgumentException 234 | */ 235 | public static function many($methods, $name, $value) 236 | { 237 | return static::getInstance($methods, $name, $value); 238 | } 239 | 240 | /** 241 | * Instance factory. 242 | * 243 | * @param array $methods 244 | * @param string $name 245 | * @param string|callable $value 246 | * 247 | * @return Route 248 | * @throws InvalidArgumentException 249 | */ 250 | protected static function getInstance($methods, $name, $value) 251 | { 252 | return new static($methods, $name, $value); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /tests/Unit/RouterTest.php: -------------------------------------------------------------------------------- 1 | createMock(ServerRequestInterface::class); 21 | 22 | $routerMock = $this->getMockBuilder(Router::class) 23 | ->disableOriginalConstructor() 24 | ->onlyMethods([]) 25 | ->getMock(); 26 | 27 | $this->assertSame($routerMock, $routerMock->setRequest($requestMock)); 28 | $this->assertSame($requestMock, $routerMock->getRequest()); 29 | } 30 | 31 | public function test_setHandlerName_With_Invalid_Parameter_Type(): void 32 | { 33 | $routerMock = $this->getMockBuilder(Router::class) 34 | ->disableOriginalConstructor() 35 | ->onlyMethods([]) 36 | ->getMock(); 37 | 38 | $this->expectException(InvalidArgumentException::class); 39 | $this->expectExceptionMessage("A route variable must be of type string, 'array' type given."); 40 | $this->expectExceptionCode(500); 41 | 42 | $routerMock->setRouteVariable(['handler']); 43 | } 44 | 45 | public function test_setHandlerName(): void 46 | { 47 | $routerMock = $this->getMockBuilder(Router::class) 48 | ->disableOriginalConstructor() 49 | ->onlyMethods([]) 50 | ->getMock(); 51 | 52 | $this->assertSame($routerMock, $routerMock->setRouteVariable('handler')); 53 | $this->assertSame('handler', $routerMock->getRouteVariable()); 54 | } 55 | 56 | /** 57 | * @dataProvider routeProvider 58 | */ 59 | public function test_setHandlers($handlers): void 60 | { 61 | $routerMock = $this->getMockBuilder(Router::class) 62 | ->disableOriginalConstructor() 63 | ->onlyMethods([]) 64 | ->getMock(); 65 | 66 | $this->assertSame($routerMock, $routerMock->setRoutes($handlers)); 67 | $this->assertSame($handlers, $routerMock->getRoutes()); 68 | } 69 | 70 | public function test_registerControllers(): void 71 | { 72 | $routerMock = $this->getMockBuilder(Router::class) 73 | ->disableOriginalConstructor() 74 | ->onlyMethods([]) 75 | ->getMock(); 76 | 77 | $controllers = [ 78 | 'FooController', 79 | 'BarController', 80 | ]; 81 | 82 | $routerMock->registerControllers($controllers); 83 | 84 | $this->assertSame($controllers, $routerMock->getControllers()); 85 | } 86 | 87 | public function getPSR7GetRequestAdapterMocked($params = []) 88 | { 89 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 90 | ->onlyMethods([ 91 | 'getMethod', 92 | 'getQueryParams' 93 | ]) 94 | ->getMockForAbstractClass(); 95 | 96 | $requestMock->expects($this->once()) 97 | ->method('getMethod') 98 | ->willReturn('GET'); 99 | 100 | $requestMock->expects($this->once()) 101 | ->method('getQueryParams') 102 | ->willReturn($params ?: [ 103 | 'function' => 'updateUser', 104 | 'userID' => '5454', 105 | 'firstname' => 'John', 106 | 'lastname' => 'Doe', 107 | ]); 108 | 109 | return $requestMock; 110 | } 111 | 112 | public function test_run_Where_Route_Parameter_Not_Found_In_Request_Variables(): void 113 | { 114 | $routerMock = $this->getMockBuilder(Router::class) 115 | ->disableOriginalConstructor() 116 | ->onlyMethods([]) 117 | ->getMock(); 118 | 119 | $routerMock 120 | ->setRequest($this->getPSR7GetRequestAdapterMocked()) 121 | ->setRouteVariable('key'); 122 | 123 | $this->expectException(BadRequestException::class); 124 | $this->expectExceptionMessage("The route parameter 'key' not found in request variables."); 125 | $this->expectExceptionCode(400); 126 | 127 | $routerMock->run(); 128 | } 129 | 130 | public function test_run_Where_Route_Name_Not_Found_In_Request_Variables(): void 131 | { 132 | $routerMock = $this->getMockBuilder(Router::class) 133 | ->disableOriginalConstructor() 134 | ->onlyMethods([]) 135 | ->getMock(); 136 | 137 | $routerMock 138 | ->setRequest($this->getPSR7GetRequestAdapterMocked(['route' => ''])) 139 | ->setRouteVariable('route'); 140 | 141 | $this->expectException(BadRequestException::class); 142 | $this->expectExceptionMessage("Route name not given."); 143 | $this->expectExceptionCode(400); 144 | 145 | $routerMock->run(); 146 | } 147 | 148 | /** 149 | * @dataProvider routeProvider 150 | */ 151 | public function test_run_Where_Route_AJAX_Request_Method_Is_Wrong($handlers): void 152 | { 153 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 154 | ->onlyMethods([ 155 | 'getMethod', 156 | 'getQueryParams' 157 | ]) 158 | ->getMockForAbstractClass(); 159 | 160 | $requestMock->expects($this->atLeastOnce()) 161 | ->method('getMethod') 162 | ->willReturn('GET'); 163 | 164 | $requestMock->expects($this->once()) 165 | ->method('getQueryParams') 166 | ->willReturn([ 167 | 'function' => 'addUser', 168 | 'firstname' => 'John', 169 | 'lastname' => 'Doe', 170 | ]); 171 | 172 | $routerMock = $this->getMockBuilder(Router::class) 173 | ->setConstructorArgs([$requestMock, 'function', $handlers]) 174 | ->onlyMethods([]) 175 | ->getMock(); 176 | 177 | $this->expectException(MethodNotAllowedException::class); 178 | $this->expectExceptionCode(405); 179 | 180 | $routerMock->run(); 181 | } 182 | 183 | /** 184 | * @dataProvider routeProvider 185 | */ 186 | public function test_run_Where_Route_Not_Found($handlers): void 187 | { 188 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 189 | ->onlyMethods(['getMethod', 'getQueryParams']) 190 | ->getMockForAbstractClass(); 191 | 192 | $requestMock->expects($this->once()) 193 | ->method('getMethod') 194 | ->willReturn('GET'); 195 | 196 | $requestMock->expects($this->atLeastOnce()) 197 | ->method('getQueryParams') 198 | ->willReturn(['function' => 'wrong']); 199 | 200 | $routerMock = $this->getMockBuilder(Router::class) 201 | ->setConstructorArgs([$requestMock, 'function', $handlers]) 202 | ->onlyMethods([]) 203 | ->getMock(); 204 | 205 | $this->expectException(RouteNotFoundException::class); 206 | $this->expectExceptionMessage('No Route found for this request.'); 207 | $this->expectExceptionCode(400); 208 | 209 | $routerMock->run(); 210 | } 211 | 212 | public function routeProvider(): array 213 | { 214 | return [ 215 | [ 216 | [ 217 | Route::get('getUsers', 'UserController@getAllUsers'), 218 | Route::post('addUser', 'UserController@addUser'), 219 | ], 220 | ], 221 | ]; 222 | } 223 | } -------------------------------------------------------------------------------- /tests/Unit/RouteResolver/RouteResolverTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Route::class) 16 | ->disableOriginalConstructor() 17 | ->onlyMethods(['getValue']) 18 | ->getMock(); 19 | 20 | $routeMock->expects($this->once()) 21 | ->method('getValue') 22 | ->willReturn('PostController@getPosts'); 23 | 24 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 25 | ->disableOriginalConstructor() 26 | ->onlyMethods(['resolveString']) 27 | ->getMock(); 28 | 29 | $handlerResolverMock->expects($this->once()) 30 | ->method('resolveString') 31 | ->with('PostController@getPosts') 32 | ->willReturn(function () { 33 | }); 34 | 35 | $this->assertInstanceOf(\Closure::class, $handlerResolverMock->resolve($routeMock)); 36 | } 37 | 38 | public function test_resolve_With_Array_Route_Type(): void 39 | { 40 | $routeMock = $this->getMockBuilder(Route::class) 41 | ->disableOriginalConstructor() 42 | ->onlyMethods(['getValue']) 43 | ->getMock(); 44 | 45 | $routeMock->expects($this->once()) 46 | ->method('getValue') 47 | ->willReturn([\stdClass::class, 'login']); 48 | 49 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 50 | ->disableOriginalConstructor() 51 | ->onlyMethods(['resolveArray']) 52 | ->getMock(); 53 | 54 | $handlerResolverMock->expects($this->once()) 55 | ->method('resolveArray') 56 | ->with([\stdClass::class, 'login']) 57 | ->willReturn(function () {}); 58 | 59 | $this->assertInstanceOf(\Closure::class, $handlerResolverMock->resolve($routeMock)); 60 | } 61 | 62 | public function test_resolve_With_Callable_array_Route_Type(): void 63 | { 64 | $class = $this->getMockBuilder(\stdClass::class) 65 | ->addMethods(['foo']) 66 | ->getMock(); 67 | 68 | $callable = [get_class($class), 'foo']; 69 | 70 | $routeMock = $this->getMockBuilder(Route::class) 71 | ->disableOriginalConstructor() 72 | ->onlyMethods(['getValue']) 73 | ->getMock(); 74 | 75 | $routeMock->expects($this->once()) 76 | ->method('getValue') 77 | ->willReturn($callable); 78 | 79 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 80 | ->disableOriginalConstructor() 81 | ->onlyMethods(['resolveArray']) 82 | ->getMock(); 83 | 84 | $handlerResolverMock->expects($this->once()) 85 | ->method('resolveArray') 86 | ->with($callable) 87 | ->willReturn(function () { 88 | }); 89 | 90 | $this->assertIsCallable($handlerResolverMock->resolve($routeMock)); 91 | } 92 | 93 | public function test_resolve_With_Invalid_Route_Type(): void 94 | { 95 | $routeMock = $this->getMockBuilder(Route::class) 96 | ->disableOriginalConstructor() 97 | ->onlyMethods(['getValue']) 98 | ->getMock(); 99 | 100 | $routeMock->expects($this->once()) 101 | ->method('getValue') 102 | ->willReturn(new \stdClass()); 103 | 104 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 105 | ->disableOriginalConstructor() 106 | ->onlyMethods([]) 107 | ->getMock(); 108 | 109 | $this->expectException(UnexpectedValueException::class); 110 | $this->expectExceptionCode(500); 111 | 112 | $this->assertIsCallable($handlerResolverMock->resolve($routeMock)); 113 | } 114 | 115 | public function test_getRegisteredControllerByName_Where_Controller_Is_Registered(): void 116 | { 117 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 118 | ->disableOriginalConstructor() 119 | ->getMock(); 120 | 121 | $controllers = [ 122 | 'App\Controller\FooController', 123 | 'App\Controller\BarController', 124 | ]; 125 | 126 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 127 | ->setConstructorArgs([$requestMock, [], $controllers]) 128 | ->onlyMethods(['getControllerName']) 129 | ->getMock(); 130 | 131 | $handlerResolverMock->expects($this->exactly(2)) 132 | ->method('getControllerName') 133 | ->willReturnOnConsecutiveCalls( 134 | 'FooController', 135 | 'BarController' 136 | ); 137 | 138 | $method = $this->getReflectedMethod('getRegisteredControllerByName'); 139 | 140 | $this->assertSame('App\Controller\BarController', $method->invoke($handlerResolverMock, 'BarController')); 141 | } 142 | 143 | public function test_getRegisteredControllerByName_Where_Controller_Is_Not_Registered(): void 144 | { 145 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 146 | ->disableOriginalConstructor() 147 | ->getMock(); 148 | 149 | $controllers = [ 150 | 'App\Controller\FooController', 151 | ]; 152 | 153 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 154 | ->setConstructorArgs([$requestMock, [], $controllers]) 155 | ->onlyMethods(['getControllerName']) 156 | ->getMock(); 157 | 158 | $handlerResolverMock->expects($this->once()) 159 | ->method('getControllerName') 160 | ->willReturnOnConsecutiveCalls( 161 | 'FooController', 162 | ); 163 | 164 | $method = $this->getReflectedMethod('getRegisteredControllerByName'); 165 | 166 | $this->assertNull($method->invoke($handlerResolverMock, 'BarController')); 167 | } 168 | 169 | public function test_getControllerName_With_Controller_Name(): void 170 | { 171 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 172 | ->disableOriginalConstructor() 173 | ->getMock(); 174 | 175 | $method = $this->getReflectedMethod('getControllerName'); 176 | 177 | $this->assertSame('FooController', $method->invoke($handlerResolverMock, 'App\Controller\FooController')); 178 | } 179 | 180 | public function test_getControllerName_With_Controller_Instance(): void 181 | { 182 | $requestMock = $this->getMockBuilder(\AmraniCh\AjaxRouter\Http\Request::class) 183 | ->disableOriginalConstructor() 184 | ->getMock(); 185 | 186 | $handlerResolverMock = $this->getMockBuilder(RouteResolver::class) 187 | ->disableOriginalConstructor() 188 | ->getMock(); 189 | 190 | $method = $this->getReflectedMethod('getControllerName'); 191 | 192 | $this->assertSame('stdClass', $method->invoke($handlerResolverMock, new \stdClass())); 193 | } 194 | 195 | /** 196 | * Gets accessible reflected method for private/protected methods. 197 | * 198 | * @param string $name 199 | * 200 | * @return \ReflectionMethod 201 | * @throws \ReflectionException 202 | */ 203 | protected function getReflectedMethod(string $name): \ReflectionMethod 204 | { 205 | $reflectedClass = new \ReflectionClass(RouteResolver::class); 206 | 207 | $method = $reflectedClass->getMethod($name); 208 | 209 | $method->setAccessible(true); 210 | 211 | return $method; 212 | } 213 | } -------------------------------------------------------------------------------- /tests/Unit/Psr7/Psr7RequestAdapterTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(ServerRequestInterface::class) 17 | ->onlyMethods([ 18 | 'getMethod', 19 | 'hasHeader', 20 | 'getHeaderLine', 21 | 'getBody', 22 | ]) 23 | ->getMockForAbstractClass(); 24 | 25 | $requestMock->expects($this->once()) 26 | ->method('getMethod') 27 | ->willReturn('POST'); 28 | 29 | $requestMock->expects($this->atLeastOnce()) 30 | ->method('hasHeader') 31 | ->with('Content-Type') 32 | ->willReturn(true); 33 | 34 | $requestMock->expects($this->atLeastOnce()) 35 | ->method('getHeaderLine') 36 | ->with('Content-Type') 37 | ->willReturn('application/json;'); 38 | 39 | $streamMock = $this->getMockBuilder(StreamInterface::class) 40 | ->onlyMethods(['getContents']) 41 | ->getMockForAbstractClass(); 42 | 43 | $streamMock->expects($this->once()) 44 | ->method('getContents') 45 | ->willReturn('{"id":64,"firstname":"John","lastname":"Doe"}'); 46 | 47 | $requestMock->expects($this->once()) 48 | ->method('getBody') 49 | ->willReturn($streamMock); 50 | 51 | $adapter = new Psr7RequestAdapter($requestMock); 52 | 53 | $this->assertSame($adapter->getVariables(), [ 54 | 'id' => 64, 55 | 'firstname' => 'John', 56 | 'lastname' => 'Doe' 57 | ]); 58 | } 59 | 60 | public function test_getVariables_With_POST_Method_With_Response_Urlencoded_Content_Type(): void 61 | { 62 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 63 | ->onlyMethods([ 64 | 'getMethod', 65 | 'hasHeader', 66 | 'getParsedBody', 67 | ]) 68 | ->getMockForAbstractClass(); 69 | 70 | $requestMock->expects($this->atLeastOnce()) 71 | ->method('getMethod') 72 | ->willReturn('POST'); 73 | 74 | $requestMock->expects($this->atLeastOnce()) 75 | ->method('hasHeader') 76 | ->with('Content-Type') 77 | ->willReturn(true); 78 | 79 | $requestMock->expects($this->atLeastOnce()) 80 | ->method('getHeaderLine') 81 | ->with('Content-Type') 82 | ->willReturn('application/x-www-form-urlencoded;'); 83 | 84 | $requestMock->expects($this->once()) 85 | ->method('getParsedBody') 86 | ->willReturn([ 87 | 'id' => '64', 88 | 'firstname' => 'John', 89 | 'lastname' => 'Doe' 90 | ]); 91 | 92 | $adapter = new Psr7RequestAdapter($requestMock); 93 | 94 | $this->assertSame($adapter->getVariables(), [ 95 | 'id' => '64', 96 | 'firstname' => 'John', 97 | 'lastname' => 'Doe' 98 | ]); 99 | } 100 | 101 | public function test_getVariables_With_POST_Method_With_Response_FormData_Content_Type(): void 102 | { 103 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 104 | ->onlyMethods([ 105 | 'getMethod', 106 | 'hasHeader', 107 | 'getParsedBody', 108 | 'getUploadedFiles', 109 | ]) 110 | ->getMockForAbstractClass(); 111 | 112 | $requestMock->expects($this->atLeastOnce()) 113 | ->method('getMethod') 114 | ->willReturn('POST'); 115 | 116 | $requestMock->expects($this->atLeastOnce()) 117 | ->method('hasHeader') 118 | ->with('Content-Type') 119 | ->willReturn(true); 120 | 121 | $requestMock->expects($this->atLeastOnce()) 122 | ->method('getHeaderLine') 123 | ->with('Content-Type') 124 | ->willReturn('multipart/form-data;'); 125 | 126 | $parsedBody = [ 127 | 'id' => '64', 128 | 'firstname' => 'John', 129 | 'lastname' => 'Doe' 130 | ]; 131 | 132 | $requestMock->expects($this->once()) 133 | ->method('getParsedBody') 134 | ->willReturn([ 135 | 'id' => '64', 136 | 'firstname' => 'John', 137 | 'lastname' => 'Doe' 138 | ]); 139 | 140 | $uploadedFiles = [$this->getMockClass(UploadedFileInterface::class)]; 141 | 142 | $requestMock->expects($this->once()) 143 | ->method('getUploadedFiles') 144 | ->willReturn($uploadedFiles); 145 | 146 | $adapter = new Psr7RequestAdapter($requestMock); 147 | 148 | $this->assertSame([ 149 | 'id' => '64', 150 | 'firstname' => 'John', 151 | 'lastname' => 'Doe', 152 | $this->getMockClass(UploadedFileInterface::class) 153 | ], $adapter->getVariables()); 154 | } 155 | 156 | public function test_getVariables_With_POST_Method_With_Response_Content_Urlencoded_Content_Type_With_Empty_Body(): void 157 | { 158 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 159 | ->onlyMethods([ 160 | 'getMethod', 161 | 'hasHeader', 162 | 'getParsedBody', 163 | ]) 164 | ->getMockForAbstractClass(); 165 | 166 | $requestMock->expects($this->atLeastOnce()) 167 | ->method('getMethod') 168 | ->willReturn('POST'); 169 | 170 | $requestMock->expects($this->atLeastOnce()) 171 | ->method('hasHeader') 172 | ->with('Content-Type') 173 | ->willReturn(true); 174 | 175 | $requestMock->expects($this->atLeastOnce()) 176 | ->method('getHeaderLine') 177 | ->with('Content-Type') 178 | ->willReturn('application/x-www-form-urlencoded;'); 179 | 180 | $requestMock->expects($this->once()) 181 | ->method('getParsedBody') 182 | ->willReturn(''); 183 | 184 | $adapter = new Psr7RequestAdapter($requestMock); 185 | 186 | $this->assertEmpty($adapter->getVariables()); 187 | } 188 | 189 | public function test_getVariables_With_POST_Method_Where_Reponse_Content_Type_Header_Not_Present(): void 190 | { 191 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 192 | ->onlyMethods([ 193 | 'getMethod', 194 | 'hasHeader', 195 | ]) 196 | ->getMockForAbstractClass(); 197 | 198 | $requestMock->expects($this->atLeastOnce()) 199 | ->method('getMethod') 200 | ->willReturn('POST'); 201 | 202 | $requestMock->expects($this->atLeastOnce()) 203 | ->method('hasHeader') 204 | ->with('Content-Type') 205 | ->willReturn(false); 206 | 207 | $adapter = new Psr7RequestAdapter($requestMock); 208 | 209 | $this->assertEmpty($adapter->getVariables()); 210 | } 211 | 212 | public function test_getVariables_With_Get_HTTP_Method_And_JSON_Response_Content_Type() 213 | { 214 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 215 | ->onlyMethods([ 216 | 'getMethod', 217 | 'hasHeader', 218 | 'getHeaderLine', 219 | 'getUri', 220 | 'getQueryParams' 221 | ]) 222 | ->getMockForAbstractClass(); 223 | 224 | $requestMock->expects($this->once()) 225 | ->method('getMethod') 226 | ->willReturn('GET'); 227 | 228 | $requestMock->expects($this->once()) 229 | ->method('hasHeader') 230 | ->willReturn(true); 231 | 232 | $requestMock->expects($this->once()) 233 | ->method('getHeaderLine') 234 | ->willReturn('application/json;'); 235 | 236 | $uriInterfaceMock = $this->getMockBuilder(UriInterface::class) 237 | ->onlyMethods(['getQuery']) 238 | ->getMockForAbstractClass(); 239 | 240 | $uriInterfaceMock->expects($this->once()) 241 | ->method('getQuery') 242 | ->willReturn('{"id":64,"firstname":"John","lastname":"Doe"}'); 243 | 244 | $requestMock->expects($this->once()) 245 | ->method('getUri') 246 | ->willReturn($uriInterfaceMock); 247 | 248 | $adapter = new Psr7RequestAdapter($requestMock); 249 | 250 | $this->assertSame($adapter->getVariables(), [ 251 | 'id' => 64, 252 | 'firstname' => 'John', 253 | 'lastname' => 'Doe' 254 | ]); 255 | } 256 | 257 | public function test_getVariables_With_Get_HTTP_Method_And_Response_Content_Type_Is_Not_JSON() 258 | { 259 | $requestMock = $this->getMockBuilder(ServerRequestInterface::class) 260 | ->onlyMethods([ 261 | 'getMethod', 262 | 'hasHeader', 263 | 'getHeaderLine', 264 | 'getUri', 265 | 'getQueryParams' 266 | ]) 267 | ->getMockForAbstractClass(); 268 | 269 | $requestMock->expects($this->once()) 270 | ->method('getMethod') 271 | ->willReturn('GET'); 272 | 273 | $requestMock->expects($this->once()) 274 | ->method('hasHeader') 275 | ->willReturn(true); 276 | 277 | $requestMock->expects($this->once()) 278 | ->method('getHeaderLine') 279 | ->willReturn('application/json;'); 280 | 281 | $uriInterfaceMock = $this->getMockBuilder(UriInterface::class) 282 | ->onlyMethods(['getQuery']) 283 | ->getMockForAbstractClass(); 284 | 285 | $uriInterfaceMock->expects($this->once()) 286 | ->method('getQuery') 287 | ->willReturn('{"id":64,"firstname":"John","lastname":"Doe"}'); 288 | 289 | $requestMock->expects($this->once()) 290 | ->method('getUri') 291 | ->willReturn($uriInterfaceMock); 292 | 293 | $adapter = new Psr7RequestAdapter($requestMock); 294 | 295 | $this->assertSame($adapter->getVariables(), [ 296 | 'id' => 64, 297 | 'firstname' => 'John', 298 | 'lastname' => 'Doe' 299 | ]); 300 | } 301 | } 302 | --------------------------------------------------------------------------------