diff --git a/.gitignore b/.gitignore index 331c58f..426c2cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea -vendor \ No newline at end of file +vendor + +resources/cache \ No newline at end of file diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index ce74c07..f3b3f8a 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -8,7 +8,7 @@ class HomeController extends Controller { public function index() { - return "index method"; + } public function test() diff --git a/composer.json b/composer.json index 4dab96f..d568dd0 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "require": { "php": "^7.2", "ext-json": "*", - "pecee/simple-router": "4.2.0.6", + "php-di/php-di": "^6.0", "twig/twig": "^3.0" }, "config": { diff --git a/composer.lock b/composer.lock index c3d7e27..3f5bdc6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d96c3c8dad22cc886c62ad330566ecbc", + "content-hash": "7da10fc34e3c55211c7a134e7e79f040", "packages": [ { "name": "jeremeamia/superclosure", @@ -116,62 +116,6 @@ ], "time": "2019-11-08T13:50:10+00:00" }, - { - "name": "pecee/simple-router", - "version": "4.2.0.6", - "source": { - "type": "git", - "url": "https://github.com/skipperbent/simple-php-router.git", - "reference": "b715c48415d5e3660df668350152bba8798a6e33" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/skipperbent/simple-php-router/zipball/b715c48415d5e3660df668350152bba8798a6e33", - "reference": "b715c48415d5e3660df668350152bba8798a6e33", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": ">=7.1", - "php-di/php-di": "^6.0" - }, - "require-dev": { - "mockery/mockery": "^1", - "phpunit/phpunit": "^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Pecee\\": "src/Pecee/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Simon Sessingø", - "email": "simon.sessingoe@gmail.com" - } - ], - "description": "Simple, fast PHP router that is easy to get integrated and in almost any project. Heavily inspired by the Laravel router.", - "keywords": [ - "framework", - "input-handler", - "laravel", - "pecee", - "php", - "request-handler", - "route", - "router", - "routing", - "routing-engine", - "simple-php-router", - "url-handling" - ], - "time": "2018-11-24T23:47:02+00:00" - }, { "name": "php-di/invoker", "version": "2.0.0", diff --git a/resources/cache/8d/8d399cc9bad04668d8048ae3036be6c210d2d6db6b6a824ddae4b40902b786e2.php b/resources/cache/8d/8d399cc9bad04668d8048ae3036be6c210d2d6db6b6a824ddae4b40902b786e2.php deleted file mode 100644 index 14a3067..0000000 --- a/resources/cache/8d/8d399cc9bad04668d8048ae3036be6c210d2d6db6b6a824ddae4b40902b786e2.php +++ /dev/null @@ -1,60 +0,0 @@ -source = $this->getSourceContext(); - - $this->parent = false; - - $this->blocks = [ - ]; - } - - protected function doDisplay(array $context, array $blocks = []) - { - $macros = $this->macros; - // line 1 - echo "Werkt het: "; - echo twig_escape_filter($this->env, ($context["test"] ?? null), "html", null, true); - } - - public function getTemplateName() - { - return "index.html"; - } - - public function isTraitable() - { - return false; - } - - public function getDebugInfo() - { - return array ( 37 => 1,); - } - - public function getSourceContext() - { - return new Source("", "index.html", "C:\\Users\\maart\\PhpstormProjects\\framework\\resources\\views\\index.html"); - } -} diff --git a/resources/views/index.html b/resources/views/index.html index d444486..0e5bd0a 100644 --- a/resources/views/index.html +++ b/resources/views/index.html @@ -1 +1,3 @@ -Werkt het: {{ test }} \ No newline at end of file +Werkt het: {{ test }} +

+{{ route('kutzooi') }} \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 404f163..999f8f5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('index'); -SimpleRouter::get('/test', 'HomeController@test')->name('test'); \ No newline at end of file +Route::get('/', 'HomeController@index')->name('index'); +Route::get('/test', 'HomeController@test')->name('test'); +Route::get('/yolo', 'HomeController@test')->name('kutzooi'); \ No newline at end of file diff --git a/src/Runtime/Contracts/Container/Container.php b/src/Runtime/Contracts/Container/Container.php index cd3e4f6..872c8ca 100644 --- a/src/Runtime/Contracts/Container/Container.php +++ b/src/Runtime/Contracts/Container/Container.php @@ -1,5 +1,7 @@ getCsrfVerifier(); + if ($baseVerifier !== null) { + return $baseVerifier->getTokenProvider()->getToken(); + } + + return null; + } +} + +if(!function_exists('dump')) +{ + function dump(...$params) + { + echo '
';
+        foreach ($params as $param) {
+            var_dump($param);
+        }
+        echo '
'; + } +} + +if(!function_exists('input')) +{ + /** + * @param string|null $index Parameter index name + * @param string|null $defaultValue Default return value + * @param array ...$methods Default methods + * @return InputHandler|array|string|null + */ + function input($index = null, $defaultValue = null, ...$methods) + { + if ($index !== null) { + return request()->getInputHandler()->value($index, $defaultValue, ...$methods); + } + + return request()->getInputHandler(); + } +} + +if(!function_exists('redirect')) +{ + /** + * @param string $url + * @param int|null $code + */ + function redirect(string $url, ?int $code = null): void + { + if ($code !== null) { + response()->httpCode($code); + } + + response()->redirect($url); + } +} + +if(!function_exists('response')) +{ + /** + * @return Response + */ + function response(): Response + { + return Router::response(); + } +} + +if(!function_exists('request')) +{ + /** + * @return Request + */ + function request(): Request + { + return Router::request(); + } +} + +if(!function_exists('route')) +{ + /** + * @param string|null $name + * @param string|array|null $parameters + * @param array|null $getParams + * @return Url + */ + function route(?string $name = null, $parameters = null, ?array $getParams = null): Url + { + return Router::getUrl($name, $parameters, $getParams); + } +} + if(!function_exists('view')) { /** diff --git a/src/Runtime/Http/Input/IInputItem.php b/src/Runtime/Http/Input/IInputItem.php new file mode 100644 index 0000000..f676332 --- /dev/null +++ b/src/Runtime/Http/Input/IInputItem.php @@ -0,0 +1,22 @@ +index = $index; + + $this->errors = 0; + + // Make the name human friendly, by replace _ with space + $this->name = ucfirst(str_replace('_', ' ', strtolower($this->index))); + } + + /** + * Create from array + * + * @param array $values + * @throws InvalidArgumentException + * @return static + */ + public static function createFromArray(array $values): self + { + if (isset($values['index']) === false) { + throw new InvalidArgumentException('Index key is required'); + } + + /* Easy way of ensuring that all indexes-are set and not filling the screen with isset() */ + + $values += [ + 'tmp_name' => null, + 'type' => null, + 'size' => null, + 'name' => null, + 'error' => null, + ]; + + return (new static($values['index'])) + ->setSize((int)$values['size']) + ->setError((int)$values['error']) + ->setType($values['type']) + ->setTmpName($values['tmp_name']) + ->setFilename($values['name']); + + } + + /** + * @return string + */ + public function getIndex(): string + { + return $this->index; + } + + /** + * Set input index + * @param string $index + * @return static + */ + public function setIndex(string $index): IInputItem + { + $this->index = $index; + + return $this; + } + + /** + * @return string + */ + public function getSize(): string + { + return $this->size; + } + + /** + * Set file size + * @param int $size + * @return static + */ + public function setSize(int $size): IInputItem + { + $this->size = $size; + + return $this; + } + + /** + * Get mime-type of file + * @return string + */ + public function getMime(): string + { + return $this->getType(); + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Set type + * @param string $type + * @return static + */ + public function setType(string $type): IInputItem + { + $this->type = $type; + + return $this; + } + + /** + * Returns extension without "." + * + * @return string + */ + public function getExtension(): string + { + return pathinfo($this->getFilename(), PATHINFO_EXTENSION); + } + + /** + * Get human friendly name + * + * @return string + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Set human friendly name. + * Useful for adding validation etc. + * + * @param string $name + * @return static + */ + public function setName(string $name): IInputItem + { + $this->name = $name; + + return $this; + } + + /** + * Set filename + * + * @param string $name + * @return static + */ + public function setFilename($name): IInputItem + { + $this->filename = $name; + + return $this; + } + + /** + * Get filename + * + * @return string mixed + */ + public function getFilename(): ?string + { + return $this->filename; + } + + /** + * Move the uploaded temporary file to it's new home + * + * @param string $destination + * @return bool + */ + public function move($destination): bool + { + return move_uploaded_file($this->tmpName, $destination); + } + + /** + * Get file contents + * + * @return string + */ + public function getContents(): string + { + return file_get_contents($this->tmpName); + } + + /** + * Return true if an upload error occurred. + * + * @return bool + */ + public function hasError(): bool + { + return ($this->getError() !== 0); + } + + /** + * Get upload-error code. + * + * @return int + */ + public function getError(): int + { + return (int)$this->errors; + } + + /** + * Set error + * + * @param int $error + * @return static + */ + public function setError($error): IInputItem + { + $this->errors = (int)$error; + + return $this; + } + + /** + * @return string + */ + public function getTmpName(): string + { + return $this->tmpName; + } + + /** + * Set file temp. name + * @param string $name + * @return static + */ + public function setTmpName($name): IInputItem + { + $this->tmpName = $name; + + return $this; + } + + public function __toString(): string + { + return $this->getTmpName(); + } + + public function getValue(): ?string + { + return $this->getFilename(); + } + + /** + * @param string $value + * @return static + */ + public function setValue(string $value): IInputItem + { + $this->filename = $value; + + return $this; + } + + public function toArray(): array + { + return [ + 'tmp_name' => $this->tmpName, + 'type' => $this->type, + 'size' => $this->size, + 'name' => $this->name, + 'error' => $this->errors, + 'filename' => $this->filename, + ]; + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Input/InputHandler.php b/src/Runtime/Http/Input/InputHandler.php new file mode 100644 index 0000000..55e9b8c --- /dev/null +++ b/src/Runtime/Http/Input/InputHandler.php @@ -0,0 +1,348 @@ +request = $request; + + $this->parseInputs(); + } + + /** + * Parse input values + * + */ + public function parseInputs(): void + { + /* Parse get requests */ + if (\count($_GET) !== 0) { + $this->get = $this->parseInputItem($_GET); + } + + /* Parse post requests */ + $postVars = $_POST; + + if (\in_array($this->request->getMethod(), ['put', 'patch', 'delete'], false) === true) { + parse_str(file_get_contents('php://input'), $postVars); + } + + if (\count($postVars) !== 0) { + $this->post = $this->parseInputItem($postVars); + } + + /* Parse get requests */ + if (\count($_FILES) !== 0) { + $this->file = $this->parseFiles(); + } + } + + /** + * @return array + */ + public function parseFiles(): array + { + $list = []; + + foreach ((array)$_FILES as $key => $value) { + + // Handle array input + if (\is_array($value['name']) === false) { + $values['index'] = $key; + try { + $list[$key] = InputFile::createFromArray($values + $value); + } catch (InvalidArgumentException $e) { + + } + continue; + } + + $keys = [$key]; + $files = $this->rearrangeFile($value['name'], $keys, $value); + + if (isset($list[$key]) === true) { + $list[$key][] = $files; + } else { + $list[$key] = $files; + } + + } + + return $list; + } + + /** + * Rearrange multi-dimensional file object created by PHP. + * + * @param array $values + * @param array $index + * @param array|null $original + * @return array + */ + protected function rearrangeFile(array $values, &$index, $original): array + { + $originalIndex = $index[0]; + array_shift($index); + + $output = []; + + foreach ($values as $key => $value) { + + if (\is_array($original['name'][$key]) === false) { + + try { + + $file = InputFile::createFromArray([ + 'index' => (empty($key) === true && empty($originalIndex) === false) ? $originalIndex : $key, + 'name' => $original['name'][$key], + 'error' => $original['error'][$key], + 'tmp_name' => $original['tmp_name'][$key], + 'type' => $original['type'][$key], + 'size' => $original['size'][$key], + ]); + + if (isset($output[$key]) === true) { + $output[$key][] = $file; + continue; + } + + $output[$key] = $file; + continue; + + } catch (InvalidArgumentException $e) { + + } + } + + $index[] = $key; + + $files = $this->rearrangeFile($value, $index, $original); + + if (isset($output[$key]) === true) { + $output[$key][] = $files; + } else { + $output[$key] = $files; + } + + } + + return $output; + } + + /** + * Parse input item from array + * + * @param array $array + * @return array + */ + protected function parseInputItem(array $array): array + { + $list = []; + + foreach ($array as $key => $value) { + + // Handle array input + if (\is_array($value) === false) { + $list[$key] = new InputItem($key, $value); + continue; + } + + $output = $this->parseInputItem($value); + + $list[$key] = $output; + } + + return $list; + } + + /** + * Find input object + * + * @param string $index + * @param array ...$methods + * @return IInputItem|array|null + */ + public function find(string $index, ...$methods) + { + $element = null; + + if (\count($methods) === 0 || \in_array('get', $methods, true) === true) { + $element = $this->get($index); + } + + if (($element === null && \count($methods) === 0) || (\count($methods) !== 0 && \in_array('post', $methods, true) === true)) { + $element = $this->post($index); + } + + if (($element === null && \count($methods) === 0) || (\count($methods) !== 0 && \in_array('file', $methods, true) === true)) { + $element = $this->file($index); + } + + return $element; + } + + /** + * Get input element value matching index + * + * @param string $index + * @param string|null $defaultValue + * @param array ...$methods + * @return string|array + */ + public function value(string $index, ?string $defaultValue = null, ...$methods) + { + $input = $this->find($index, ...$methods); + + $output = []; + + /* Handle collection */ + if (\is_array($input) === true) { + /* @var $item InputItem */ + foreach ($input as $item) { + $output[] = $item->getValue(); + } + + return (\count($output) === 0) ? $defaultValue : $output; + } + + return ($input === null || ($input !== null && trim($input->getValue()) === '')) ? $defaultValue : $input->getValue(); + } + + /** + * Check if a input-item exist + * + * @param string $index + * @param array ...$methods + * @return bool + */ + public function exists(string $index, ...$methods): bool + { + return $this->value($index, null, ...$methods) !== null; + } + + /** + * Find post-value by index or return default value. + * + * @param string $index + * @param string|null $defaultValue + * @return InputItem|array|string|null + */ + public function post(string $index, ?string $defaultValue = null) + { + return $this->post[$index] ?? $defaultValue; + } + + /** + * Find file by index or return default value. + * + * @param string $index + * @param string|null $defaultValue + * @return InputFile|array|string|null + */ + public function file(string $index, ?string $defaultValue = null) + { + return $this->file[$index] ?? $defaultValue; + } + + /** + * Find parameter/query-string by index or return default value. + * + * @param string $index + * @param string|null $defaultValue + * @return InputItem|array|string|null + */ + public function get(string $index, ?string $defaultValue = null) + { + return $this->get[$index] ?? $defaultValue; + } + + /** + * Get all get/post items + * @param array $filter Only take items in filter + * @return array + */ + public function all(array $filter = []): array + { + $output = $_GET; + + if ($this->request->getMethod() === 'post') { + + // Append POST data + $output += $_POST; + $contents = file_get_contents('php://input'); + + // Append any PHP-input json + if (strpos(trim($contents), '{') === 0) { + $post = json_decode($contents, true); + if ($post !== false) { + $output += $post; + } + } + } + + return (\count($filter) > 0) ? array_intersect_key($output, array_flip($filter)) : $output; + } + + /** + * Add GET parameter + * + * @param string $key + * @param InputItem $item + */ + public function addGet(string $key, InputItem $item): void + { + $this->get[$key] = $item; + } + + /** + * Add POST parameter + * + * @param string $key + * @param InputItem $item + */ + public function addPost(string $key, InputItem $item): void + { + $this->post[$key] = $item; + } + + /** + * Add FILE parameter + * + * @param string $key + * @param InputFile $item + */ + public function addFile(string $key, InputFile $item): void + { + $this->file[$key] = $item; + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Input/InputItem.php b/src/Runtime/Http/Input/InputItem.php new file mode 100644 index 0000000..ef4e45b --- /dev/null +++ b/src/Runtime/Http/Input/InputItem.php @@ -0,0 +1,80 @@ +index = $index; + $this->value = $value; + + // Make the name human friendly, by replace _ with space + $this->name = ucfirst(str_replace('_', ' ', strtolower($this->index))); + } + + /** + * @return string + */ + public function getIndex(): string + { + return $this->index; + } + + public function setIndex(string $index): IInputItem + { + $this->index = $index; + + return $this; + } + + /** + * @return string + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Set input name + * @param string $name + * @return static + */ + public function setName(string $name): IInputItem + { + $this->name = $name; + + return $this; + } + + /** + * @return string + */ + public function getValue(): ?string + { + return $this->value; + } + + /** + * Set input value + * @param string $value + * @return static + */ + public function setValue(string $value): IInputItem + { + $this->value = $value; + + return $this; + } + + public function __toString(): string + { + return (string)$this->value; + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Middleware/BaseCsrfVerifier.php b/src/Runtime/Http/Middleware/BaseCsrfVerifier.php new file mode 100644 index 0000000..e69b0b3 --- /dev/null +++ b/src/Runtime/Http/Middleware/BaseCsrfVerifier.php @@ -0,0 +1,101 @@ +tokenProvider = new CookieTokenProvider(); + } + + /** + * Check if the url matches the urls in the except property + * @param Request $request + * @return bool + */ + protected function skip(Request $request): bool + { + if ($this->except === null || \count($this->except) === 0) { + return false; + } + + $max = \count($this->except) - 1; + + for ($i = $max; $i >= 0; $i--) { + $url = $this->except[$i]; + + $url = rtrim($url, '/'); + if ($url[\strlen($url) - 1] === '*') { + $url = rtrim($url, '*'); + $skip = $request->getUrl()->contains($url); + } else { + $skip = ($url === $request->getUrl()->getOriginalUrl()); + } + + if ($skip === true) { + return true; + } + } + + return false; + } + + /** + * Handle request + * + * @param Request $request + * @throws TokenMismatchException + */ + public function handle(Request $request): void + { + + if ($this->skip($request) === false && \in_array($request->getMethod(), ['post', 'put', 'delete'], true) === true) { + + $token = $request->getInputHandler()->value( + static::POST_KEY, + $request->getHeader(static::HEADER_KEY), + 'post' + ); + + if ($this->tokenProvider->validate((string)$token) === false) { + throw new TokenMismatchException('Invalid CSRF-token.'); + } + + } + + // Refresh existing token + $this->tokenProvider->refresh(); + + } + + public function getTokenProvider(): ITokenProvider + { + return $this->tokenProvider; + } + + /** + * Set token provider + * @param ITokenProvider $provider + */ + public function setTokenProvider(ITokenProvider $provider): void + { + $this->tokenProvider = $provider; + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Middleware/IMiddleware.php b/src/Runtime/Http/Middleware/IMiddleware.php new file mode 100644 index 0000000..9f6bf1f --- /dev/null +++ b/src/Runtime/Http/Middleware/IMiddleware.php @@ -0,0 +1,14 @@ + $value) { + $this->headers[strtolower($key)] = $value; + $this->headers[strtolower(str_replace('_', '-', $key))] = $value; + } + + $this->setHost($this->getHeader('http-host')); + + // Check if special IIS header exist, otherwise use default. + $this->setUrl(new Url($this->getHeader('unencoded-url', $this->getHeader('request-uri')))); + + $this->method = strtolower($this->getHeader('request-method')); + $this->inputHandler = new InputHandler($this); + $this->method = strtolower($this->inputHandler->value('_method', $this->getHeader('request-method'))); + } + + public function isSecure(): bool + { + return $this->getHeader('http-x-forwarded-proto') === 'https' || $this->getHeader('https') !== null || $this->getHeader('server-port') === 443; + } + + /** + * @return Url + */ + public function getUrl(): Url + { + return $this->url; + } + + /** + * Copy url object + * + * @return Url + */ + public function getUrlCopy(): Url + { + return clone $this->url; + } + + /** + * @return string|null + */ + public function getHost(): ?string + { + return $this->host; + } + + /** + * @return string|null + */ + public function getMethod(): ?string + { + return $this->method; + } + + /** + * Get http basic auth user + * @return string|null + */ + public function getUser(): ?string + { + return $this->getHeader('php-auth-user'); + } + + /** + * Get http basic auth password + * @return string|null + */ + public function getPassword(): ?string + { + return $this->getHeader('php-auth-pw'); + } + + /** + * Get all headers + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get id address + * @return string|null + */ + public function getIp(): ?string + { + if ($this->getHeader('http-cf-connecting-ip') !== null) { + return $this->getHeader('http-cf-connecting-ip'); + } + + if ($this->getHeader('http-x-forwarded-for') !== null) { + return $this->getHeader('http-x-forwarded_for'); + } + + return $this->getHeader('remote-addr'); + } + + /** + * Get remote address/ip + * + * @alias static::getIp + * @return string|null + */ + public function getRemoteAddr(): ?string + { + return $this->getIp(); + } + + /** + * Get referer + * @return string|null + */ + public function getReferer(): ?string + { + return $this->getHeader('http-referer'); + } + + /** + * Get user agent + * @return string|null + */ + public function getUserAgent(): ?string + { + return $this->getHeader('http-user-agent'); + } + + /** + * Get header value by name + * + * @param string $name + * @param string|null $defaultValue + * + * @return string|null + */ + public function getHeader($name, $defaultValue = null): ?string + { + return $this->headers[strtolower($name)] ?? $defaultValue; + } + + /** + * Get input class + * @return InputHandler + */ + public function getInputHandler(): InputHandler + { + return $this->inputHandler; + } + + /** + * Is format accepted + * + * @param string $format + * + * @return bool + */ + public function isFormatAccepted($format): bool + { + return ($this->getHeader('http-accept') !== null && stripos($this->getHeader('http-accept'), $format) !== false); + } + + /** + * Returns true if the request is made through Ajax + * + * @return bool + */ + public function isAjax(): bool + { + return (strtolower($this->getHeader('http-x-requested-with')) === 'xmlhttprequest'); + } + + /** + * Get accept formats + * @return array + */ + public function getAcceptFormats(): array + { + return explode(',', $this->getHeader('http-accept')); + } + + /** + * @param Url $url + */ + public function setUrl(Url $url): void + { + $this->url = $url; + + if ($this->url->getHost() === null) { + $this->url->setHost((string)$this->getHost()); + } + } + + /** + * @param string|null $host + */ + public function setHost(?string $host): void + { + $this->host = $host; + } + + /** + * @param string $method + */ + public function setMethod(string $method): void + { + $this->method = strtolower($method); + } + + /** + * Set rewrite route + * + * @param ILoadableRoute $route + * @return static + */ + public function setRewriteRoute(ILoadableRoute $route): self + { + $this->hasPendingRewrite = true; + $this->rewriteRoute = Route::addDefaultNamespace($route); + + return $this; + } + + /** + * Get rewrite route + * + * @return ILoadableRoute|null + */ + public function getRewriteRoute(): ?ILoadableRoute + { + return $this->rewriteRoute; + } + + /** + * Get rewrite url + * + * @return string|null + */ + public function getRewriteUrl(): ?string + { + return $this->rewriteUrl; + } + + /** + * Set rewrite url + * + * @param string $rewriteUrl + * @return static + */ + public function setRewriteUrl(string $rewriteUrl): self + { + $this->hasPendingRewrite = true; + $this->rewriteUrl = rtrim($rewriteUrl, '/') . '/'; + + return $this; + } + + /** + * Set rewrite callback + * @param string|\Closure $callback + * @return static + */ + public function setRewriteCallback($callback): self + { + $this->hasPendingRewrite = true; + + return $this->setRewriteRoute(new RouteUrl($this->getUrl()->getPath(), $callback)); + } + + /** + * Get loaded route + * @return ILoadableRoute|null + */ + public function getLoadedRoute(): ?ILoadableRoute + { + return (\count($this->loadedRoutes) > 0) ? end($this->loadedRoutes) : null; + } + + /** + * Get all loaded routes + * + * @return array + */ + public function getLoadedRoutes(): array + { + return $this->loadedRoutes; + } + + /** + * Set loaded routes + * + * @param array $routes + * @return static + */ + public function setLoadedRoutes(array $routes): self + { + $this->loadedRoutes = $routes; + + return $this; + } + + /** + * Added loaded route + * + * @param ILoadableRoute $route + * @return static + */ + public function addLoadedRoute(ILoadableRoute $route): self + { + $this->loadedRoutes[] = $route; + + return $this; + } + + /** + * Returns true if the request contains a rewrite + * + * @return bool + */ + public function hasPendingRewrite(): bool + { + return $this->hasPendingRewrite; + } + + /** + * Defines if the current request contains a rewrite. + * + * @param bool $boolean + * @return Request + */ + public function setHasPendingRewrite(bool $boolean): self + { + $this->hasPendingRewrite = $boolean; + + return $this; + } + + public function __isset($name) + { + return array_key_exists($name, $this->data) === true; + } + + public function __set($name, $value = null) + { + $this->data[$name] = $value; + } + + public function __get($name) + { + return $this->data[$name] ?? null; + } + +} diff --git a/src/Runtime/Http/Response.php b/src/Runtime/Http/Response.php new file mode 100644 index 0000000..a569430 --- /dev/null +++ b/src/Runtime/Http/Response.php @@ -0,0 +1,130 @@ +request = $request; + } + + /** + * Set the http status code + * + * @param int $code + * @return static + */ + public function httpCode(int $code): self + { + http_response_code($code); + + return $this; + } + + /** + * Redirect the response + * + * @param string $url + * @param int $httpCode + */ + public function redirect(string $url, ?int $httpCode = null): void + { + if ($httpCode !== null) { + $this->httpCode($httpCode); + } + + $this->header('location: ' . $url); + exit(0); + } + + public function refresh(): void + { + $this->redirect($this->request->getUrl()->getOriginalUrl()); + } + + /** + * Add http authorisation + * @param string $name + * @return static + */ + public function auth(string $name = ''): self + { + $this->headers([ + 'WWW-Authenticate: Basic realm="' . $name . '"', + 'HTTP/1.0 401 Unauthorized', + ]); + + return $this; + } + + public function cache(string $eTag, int $lastModifiedTime = 2592000): self + { + + $this->headers([ + 'Cache-Control: public', + sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $lastModifiedTime)), + sprintf('Etag: %s', $eTag), + ]); + + $httpModified = $this->request->getHeader('http-if-modified-since'); + $httpIfNoneMatch = $this->request->getHeader('http-if-none-match'); + + if (($httpIfNoneMatch !== null && $httpIfNoneMatch === $eTag) || ($httpModified !== null && strtotime($httpModified) === $lastModifiedTime)) { + + $this->header('HTTP/1.1 304 Not Modified'); + exit(0); + } + + return $this; + } + + /** + * Json encode + * @param array|\JsonSerializable $value + * @param int $options JSON options Bitmask consisting of JSON_HEX_QUOT, JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS, JSON_NUMERIC_CHECK, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES, JSON_FORCE_OBJECT, JSON_PRESERVE_ZERO_FRACTION, JSON_UNESCAPED_UNICODE, JSON_PARTIAL_OUTPUT_ON_ERROR. + * @param int $dept JSON debt. + * @throws InvalidArgumentException + */ + public function json($value, ?int $options = null, int $dept = 512): void + { + if (($value instanceof \JsonSerializable) === false && \is_array($value) === false) { + throw new InvalidArgumentException('Invalid type for parameter "value". Must be of type array or object implementing the \JsonSerializable interface.'); + } + + $this->header('Content-Type: application/json; charset=utf-8'); + echo json_encode($value, $options, $dept); + exit(0); + } + + /** + * Add header to response + * @param string $value + * @return static + */ + public function header(string $value): self + { + header($value); + + return $this; + } + + /** + * Add multiple headers to response + * @param array $headers + * @return static + */ + public function headers(array $headers): self + { + foreach ($headers as $header) { + $this->header($header); + } + + return $this; + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Security/CookieTokenProvider.php b/src/Runtime/Http/Security/CookieTokenProvider.php new file mode 100644 index 0000000..7d8deaa --- /dev/null +++ b/src/Runtime/Http/Security/CookieTokenProvider.php @@ -0,0 +1,118 @@ +token = $this->getToken(); + + if ($this->token === null) { + $this->token = $this->generateToken(); + } + } + + /** + * Generate random identifier for CSRF token + * + * @return string + * @throws SecurityException + */ + public function generateToken(): string + { + try { + return bin2hex(random_bytes(32)); + } catch (\Exception $e) { + throw new SecurityException($e->getMessage(), (int)$e->getCode(), $e->getPrevious()); + } + } + + /** + * Validate valid CSRF token + * + * @param string $token + * @return bool + */ + public function validate(string $token): bool + { + if ($this->getToken() !== null) { + return hash_equals($token, $this->getToken()); + } + + return false; + } + + /** + * Set csrf token cookie + * Overwrite this method to save the token to another storage like session etc. + * + * @param string $token + */ + public function setToken(string $token): void + { + $this->token = $token; + setcookie(static::CSRF_KEY, $token, (time() + 60) * $this->cookieTimeoutMinutes, '/', ini_get('session.cookie_domain'), ini_get('session.cookie_secure'), ini_get('session.cookie_httponly')); + } + + /** + * Get csrf token + * @param string|null $defaultValue + * @return string|null + */ + public function getToken(?string $defaultValue = null): ?string + { + $this->token = ($this->hasToken() === true) ? $_COOKIE[static::CSRF_KEY] : null; + + return $this->token ?? $defaultValue; + } + + /** + * Refresh existing token + */ + public function refresh(): void + { + if ($this->token !== null) { + $this->setToken($this->token); + } + } + + /** + * Returns whether the csrf token has been defined + * @return bool + */ + public function hasToken(): bool + { + return isset($_COOKIE[static::CSRF_KEY]); + } + + /** + * Get timeout for cookie in minutes + * @return int + */ + public function getCookieTimeoutMinutes(): int + { + return $this->cookieTimeoutMinutes; + } + + /** + * Set cookie timeout in minutes + * @param int $minutes + */ + public function setCookieTimeoutMinutes(int $minutes): void + { + $this->cookieTimeoutMinutes = $minutes; + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Security/ITokenProvider.php b/src/Runtime/Http/Security/ITokenProvider.php new file mode 100644 index 0000000..3cba082 --- /dev/null +++ b/src/Runtime/Http/Security/ITokenProvider.php @@ -0,0 +1,29 @@ +originalUrl = $url; + + if ($url !== null && $url !== '/') { + $data = $this->parseUrl($url); + + $this->scheme = $data['scheme'] ?? null; + $this->host = $data['host'] ?? null; + $this->port = $data['port'] ?? null; + $this->username = $data['user'] ?? null; + $this->password = $data['pass'] ?? null; + + if (isset($data['path']) === true) { + $this->setPath($data['path']); + } + + $this->fragment = $data['fragment'] ?? null; + + if (isset($data['query']) === true) { + $this->setQueryString($data['query']); + } + } + } + + /** + * Check if url is using a secure protocol like https + * + * @return bool + */ + public function isSecure(): bool + { + return (strtolower($this->getScheme()) === 'https'); + } + + /** + * Checks if url is relative + * + * @return bool + */ + public function isRelative(): bool + { + return ($this->getHost() === null); + } + + /** + * Get url scheme + * + * @return string|null + */ + public function getScheme(): ?string + { + return $this->scheme; + } + + /** + * Set the scheme of the url + * + * @param string $scheme + * @return static + */ + public function setScheme(string $scheme): self + { + $this->scheme = $scheme; + + return $this; + } + + /** + * Get url host + * + * @return string|null + */ + public function getHost(): ?string + { + return $this->host; + } + + /** + * Set the host of the url + * + * @param string $host + * @return static + */ + public function setHost(string $host): self + { + $this->host = $host; + + return $this; + } + + /** + * Get url port + * + * @return int|null + */ + public function getPort(): ?int + { + return ($this->port !== null) ? (int)$this->port : null; + } + + /** + * Set the port of the url + * + * @param int $port + * @return static + */ + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + /** + * Parse username from url + * + * @return string|null + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * Set the username of the url + * + * @param string $username + * @return static + */ + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + /** + * Parse password from url + * @return string|null + */ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * Set the url password + * + * @param string $password + * @return static + */ + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * Get path from url + * @return string + */ + public function getPath(): ?string + { + return $this->path ?? '/'; + } + + /** + * Set the url path + * + * @param string $path + * @return static + */ + public function setPath(string $path): self + { + $this->path = rtrim($path, '/') . '/'; + + return $this; + } + + /** + * Get the name of the current route + * + * @return string|null + */ + public function getName(): ?string + { + return request()->getLoadedRoute()->getName(); + } + + /** + * Set the name of the current route + * + * @param string $name + * @return static + */ + public function setName(string $name): ?self + { + request()->getLoadedRoute()->setName($name); + + return $this; + } + + /** + * Get query-string from url + * + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * Merge parameters array + * + * @param array $params + * @return static + */ + public function mergeParams(array $params): self + { + return $this->setParams(array_merge($this->getParams(), $params)); + } + + /** + * Set the url params + * + * @param array $params + * @return static + */ + public function setParams(array $params): self + { + $this->params = $params; + + return $this; + } + + /** + * Set raw query-string parameters as string + * + * @param string $queryString + * @return static + */ + public function setQueryString(string $queryString): self + { + $params = []; + + if(parse_str($queryString, $params) !== false) { + return $this->setParams($params); + } + + return $this; + } + + /** + * Get query-string params as string + * + * @return string + */ + public function getQueryString(): string + { + return static::arrayToParams($this->getParams()); + } + + /** + * Get fragment from url (everything after #) + * + * @return string|null + */ + public function getFragment(): ?string + { + return $this->fragment; + } + + /** + * Set url fragment + * + * @param string $fragment + * @return static + */ + public function setFragment(string $fragment): self + { + $this->fragment = $fragment; + + return $this; + } + + /** + * @return string + */ + public function getOriginalUrl(): string + { + return $this->originalUrl; + } + + /** + * Get position of value. + * Returns -1 on failure. + * + * @param string $value + * @return int + */ + public function indexOf(string $value): int + { + $index = stripos($this->getOriginalUrl(), $value); + + return ($index === false) ? -1 : $index; + } + + /** + * Check if url contains value. + * + * @param string $value + * @return bool + */ + public function contains(string $value): bool + { + return (stripos($this->getOriginalUrl(), $value) !== false); + } + + /** + * Check if url contains parameter/query string. + * + * @param string $name + * @return bool + */ + public function hasParam(string $name): bool + { + return array_key_exists($name, $this->getParams()); + } + + /** + * Removes multiple parameters from the query-string + * + * @param array ...$names + * @return static + */ + public function removeParams(...$names): self + { + $params = array_diff_key($this->getParams(), array_flip($names)); + $this->setParams($params); + + return $this; + } + + /** + * Removes parameter from the query-string + * + * @param string $name + * @return static + */ + public function removeParam(string $name): self + { + $params = $this->getParams(); + unset($params[$name]); + $this->setParams($params); + + return $this; + } + + /** + * Get parameter by name. + * Returns parameter value or default value. + * + * @param string $name + * @param string|null $defaultValue + * @return string|null + */ + public function getParam(string $name, ?string $defaultValue = null): ?string + { + return isset($this->getParams()[$name]) ?? $defaultValue; + } + + /** + * UTF-8 aware parse_url() replacement. + * @param string $url + * @param int $component + * @return array + * @throws MalformedUrlException + */ + public function parseUrl(string $url, int $component = -1): array + { + $encodedUrl = preg_replace_callback( + '/[^:\/@?&=#]+/u', + function ($matches) { + return urlencode($matches[0]); + }, + $url + ); + + $parts = parse_url($encodedUrl, $component); + + if ($parts === false) { + throw new MalformedUrlException(sprintf('Failed to parse url: "%s"', $url)); + } + + return array_map('urldecode', $parts); + } + + /** + * Convert array to query-string params + * + * @param array $getParams + * @param bool $includeEmpty + * @return string + */ + public static function arrayToParams(array $getParams = [], bool $includeEmpty = true): string + { + if (\count($getParams) !== 0) { + + if ($includeEmpty === false) { + $getParams = array_filter($getParams, function ($item) { + return (trim($item) !== ''); + }); + } + + return http_build_query($getParams); + } + + return ''; + } + + /** + * Returns the relative url + * + * @return string + */ + public function getRelativeUrl(): string + { + $params = $this->getQueryString(); + + $path = $this->path ?? ''; + $query = $params !== '' ? '?' . $params : ''; + $fragment = $this->fragment !== null ? '#' . $this->fragment : ''; + + return $path . $query . $fragment; + } + + /** + * Returns the absolute url + * + * @return string + */ + public function getAbsoluteUrl(): string + { + $scheme = $this->scheme !== null ? $this->scheme . '://' : ''; + $host = $this->host ?? ''; + $port = $this->port !== null ? ':' . $this->port : ''; + $user = $this->username ?? ''; + $pass = $this->password !== null ? ':' . $this->password : ''; + $pass = ($user || $pass) ? $pass . '@' : ''; + + return $scheme . $user . $pass . $host . $port . $this->getRelativeUrl(); + } + + /** + * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize(): string + { + return $this->getRelativeUrl(); + } + + public function __toString(): string + { + return $this->getRelativeUrl(); + } + +} \ No newline at end of file diff --git a/src/Runtime/Http/Views/Factory.php b/src/Runtime/Http/View/Factory.php similarity index 57% rename from src/Runtime/Http/Views/Factory.php rename to src/Runtime/Http/View/Factory.php index 7a3621e..49b37fb 100644 --- a/src/Runtime/Http/Views/Factory.php +++ b/src/Runtime/Http/View/Factory.php @@ -1,16 +1,15 @@ resourcePath() . 'views'); - $this->twig = new Environment($loader, [ - 'cache' => app()->resourcePath() . 'cache' - ]); - } - /** * @param $name * @param array $arguments + * @return string */ public function make($name, $arguments = []) { @@ -48,7 +32,7 @@ class Factory implements \Runtime\Contracts\View\Factory { $this->arguments = $arguments; try { - $this->render(); + return $this->render(); } catch (\Exception $e) { ExceptionHandler::make($e); @@ -56,15 +40,14 @@ class Factory implements \Runtime\Contracts\View\Factory { } /** - * @param $name - * @param $arguments + * @return string * @throws LoaderError * @throws RuntimeError * @throws SyntaxError */ private function render() { - echo $this->twig->render($this->name . '.html', $this->arguments); + return app()->view()->render($this->name . '.html', $this->arguments); } /** diff --git a/src/Runtime/Http/View/ViewEngine.php b/src/Runtime/Http/View/ViewEngine.php new file mode 100644 index 0000000..516907f --- /dev/null +++ b/src/Runtime/Http/View/ViewEngine.php @@ -0,0 +1,53 @@ +resourcePath() . 'views'); + + self::$instance = new Environment($loader, [ + //'cache' => app()->resourcePath() . 'cache' + ]); + + $this->loadHelperFunctions(); + } + + /** + * Load all helper functions as Twig Function + */ + private function loadHelperFunctions() + { + $functions = $this->getHelperFunctions(); + + foreach ($functions as $function) + { + $function = new TwigFunction($function, function (...$params) use ($function) { + return call_user_func_array($function, $params); + }); + + self::$instance->addFunction($function); + } + } + + /** + * @return Environment + */ + public static function get() + { + return self::$instance; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/ClassLoader/ClassLoader.php b/src/Runtime/Router/ClassLoader/ClassLoader.php new file mode 100644 index 0000000..1280083 --- /dev/null +++ b/src/Runtime/Router/ClassLoader/ClassLoader.php @@ -0,0 +1,118 @@ +useDependencyInjection === true) { + $container = $this->getContainer(); + if ($container !== null) { + try { + return $container->get($class); + } catch (\Exception $e) { + throw new NotFoundHttpException($e->getMessage(), (int)$e->getCode(), $e->getPrevious()); + } + } + } + + return new $class(); + } + + /** + * Load closure + * + * @param \Closure $closure + * @param array $parameters + * @return mixed + * @throws NotFoundHttpException + */ + public function loadClosure(\Closure $closure, array $parameters) + { + if ($this->useDependencyInjection === true) { + $container = $this->getContainer(); + if ($container !== null) { + try { + return $container->call($closure, $parameters); + } catch (\Exception $e) { + throw new NotFoundHttpException($e->getMessage(), (int)$e->getCode(), $e->getPrevious()); + } + } + } + + return \call_user_func_array($closure, $parameters); + } + + /** + * Get dependency injector container. + * + * @return Container|null + */ + public function getContainer(): ?Container + { + return $this->container; + } + + /** + * Set the dependency-injector container. + * + * @param Container $container + * @return ClassLoader + */ + public function setContainer(Container $container): self + { + $this->container = $container; + + return $this; + } + + /** + * Enable or disable dependency injection. + * + * @param bool $enabled + * @return static + */ + public function useDependencyInjection(bool $enabled): self + { + $this->useDependencyInjection = $enabled; + + return $this; + } + + /** + * Return true if dependency injection is enabled. + * + * @return bool + */ + public function isDependencyInjectionEnabled(): bool + { + return $this->useDependencyInjection; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/ClassLoader/IClassLoader.php b/src/Runtime/Router/ClassLoader/IClassLoader.php new file mode 100644 index 0000000..0e96f05 --- /dev/null +++ b/src/Runtime/Router/ClassLoader/IClassLoader.php @@ -0,0 +1,12 @@ +eventName = $eventName; + $this->router = $router; + $this->arguments = $arguments; + } + + /** + * Get event name + * + * @return string + */ + public function getEventName(): string + { + return $this->eventName; + } + + /** + * Set the event name + * + * @param string $name + */ + public function setEventName(string $name): void + { + $this->eventName = $name; + } + + /** + * Get the router instance + * + * @return Router + */ + public function getRouter(): Router + { + return $this->router; + } + + /** + * Get the request instance + * + * @return Request + */ + public function getRequest(): Request + { + return $this->getRouter()->getRequest(); + } + + /** + * @param string $name + * @return mixed + */ + public function __get($name) + { + return $this->arguments[$name] ?? null; + } + + /** + * @param string $name + * @return bool + */ + public function __isset($name) + { + return array_key_exists($name, $this->arguments); + } + + /** + * @param string $name + * @param mixed $value + * @throws \InvalidArgumentException + */ + public function __set($name, $value) + { + throw new \InvalidArgumentException('Not supported'); + } + + /** + * Get arguments + * + * @return array + */ + public function getArguments(): array + { + return $this->arguments; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Event/IEventArgument.php b/src/Runtime/Router/Event/IEventArgument.php new file mode 100644 index 0000000..80af5f4 --- /dev/null +++ b/src/Runtime/Router/Event/IEventArgument.php @@ -0,0 +1,46 @@ +callback = $callback; + } + + /** + * @param Request $request + * @param \Exception $error + */ + public function handleError(Request $request, \Exception $error): void + { + /* Fire exceptions */ + \call_user_func($this->callback, + $request, + $error + ); + } +} \ No newline at end of file diff --git a/src/Runtime/Router/Handlers/DebugEventHandler.php b/src/Runtime/Router/Handlers/DebugEventHandler.php new file mode 100644 index 0000000..d9195f8 --- /dev/null +++ b/src/Runtime/Router/Handlers/DebugEventHandler.php @@ -0,0 +1,62 @@ +callback = function (EventArgument $argument) { + // todo: log in database + }; + } + + /** + * Get events. + * + * @param string|null $name Filter events by name. + * @return array + */ + public function getEvents(?string $name): array + { + return [ + $name => [ + $this->callback, + ], + ]; + } + + /** + * Fires any events registered with given event-name + * + * @param Router $router Router instance + * @param string $name Event name + * @param array $eventArgs Event arguments + */ + public function fireEvents(Router $router, string $name, array $eventArgs = []): void + { + $callback = $this->callback; + $callback(new EventArgument($router, $eventArgs)); + } + + /** + * Set debug callback + * + * @param \Closure $event + */ + public function setCallback(\Closure $event): void + { + $this->callback = $event; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Handlers/EventHandler.php b/src/Runtime/Router/Handlers/EventHandler.php new file mode 100644 index 0000000..7a2d913 --- /dev/null +++ b/src/Runtime/Router/Handlers/EventHandler.php @@ -0,0 +1,184 @@ +registeredEvents[$name]) === true) { + $this->registeredEvents[$name][] = $callback; + } else { + $this->registeredEvents[$name] = [$callback]; + } + + return $this; + } + + /** + * Get events. + * + * @param string|null $name Filter events by name. + * @param array ...$names Add multiple names... + * @return array + */ + public function getEvents(?string $name, ...$names): array + { + if ($name === null) { + return $this->registeredEvents; + } + + $names[] = $name; + $events = []; + + foreach ($names as $eventName) { + if (isset($this->registeredEvents[$eventName]) === true) { + $events += $this->registeredEvents[$eventName]; + } + } + + return $events; + } + + /** + * Fires any events registered with given event-name + * + * @param Router $router Router instance + * @param string $name Event name + * @param array $eventArgs Event arguments + */ + public function fireEvents(Router $router, string $name, array $eventArgs = []): void + { + $events = $this->getEvents(static::EVENT_ALL, $name); + + /* @var $event \Closure */ + foreach ($events as $event) { + $event(new EventArgument($name, $router, $eventArgs)); + } + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Handlers/IEventHandler.php b/src/Runtime/Router/Handlers/IEventHandler.php new file mode 100644 index 0000000..babc8d3 --- /dev/null +++ b/src/Runtime/Router/Handlers/IEventHandler.php @@ -0,0 +1,27 @@ +start(); + } + + /** + * Start the routing an return array with debugging-information + * + * @return array + */ + public static function startDebug(): array + { + $routerOutput = null; + + try { + ob_start(); + static::router()->setDebugEnabled(true)->start(); + $routerOutput = ob_get_contents(); + ob_end_clean(); + } catch (\Exception $e) { + + } + + // Try to parse library version + $composerFile = \dirname(__DIR__, 3) . '/composer.lock'; + $version = false; + + if (is_file($composerFile) === true) { + $composerInfo = json_decode(file_get_contents($composerFile), true); + + if (isset($composerInfo['packages']) === true && \is_array($composerInfo['packages']) === true) { + foreach ($composerInfo['packages'] as $package) { + if (isset($package['name']) === true && strtolower($package['name']) === 'pecee/simple-router') { + $version = $package['version']; + break; + } + } + } + } + + $request = static::request(); + $router = static::router(); + + return [ + 'url' => $request->getUrl(), + 'method' => $request->getMethod(), + 'host' => $request->getHost(), + 'loaded_routes' => $request->getLoadedRoutes(), + 'all_routes' => $router->getRoutes(), + 'boot_managers' => $router->getBootManagers(), + 'csrf_verifier' => $router->getCsrfVerifier(), + 'log' => $router->getDebugLog(), + 'event_handlers' => $router->getEventHandlers(), + 'router_output' => $routerOutput, + 'library_version' => $version, + 'php_version' => PHP_VERSION, + 'server_params' => $request->getHeaders(), + ]; + } + + /** + * Set default namespace which will be prepended to all routes. + * + * @param string $defaultNamespace + */ + public static function setDefaultNamespace(string $defaultNamespace): void + { + static::$defaultNamespace = $defaultNamespace; + } + + /** + * Base CSRF verifier + * + * @param BaseCsrfVerifier $baseCsrfVerifier + */ + public static function csrfVerifier(BaseCsrfVerifier $baseCsrfVerifier): void + { + static::router()->setCsrfVerifier($baseCsrfVerifier); + } + + /** + * Add new event handler to the router + * + * @param IEventHandler $eventHandler + */ + public static function addEventHandler(IEventHandler $eventHandler): void + { + static::router()->addEventHandler($eventHandler); + } + + /** + * Boot managers allows you to alter the routes before the routing occurs. + * Perfect if you want to load pretty-urls from a file or database. + * + * @param IRouterBootManager $bootManager + */ + public static function addBootManager(IRouterBootManager $bootManager): void + { + static::router()->addBootManager($bootManager); + } + + /** + * Redirect to when route matches. + * + * @param string $where + * @param string $to + * @param int $httpCode + * @return IRoute + */ + public static function redirect($where, $to, $httpCode = 301): IRoute + { + return static::get($where, function () use ($to, $httpCode) { + static::response()->redirect($to, $httpCode); + }); + } + + /** + * Route the given url to your callback on GET request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * + * @return RouteUrl + */ + public static function get(string $url, $callback, array $settings = null): IRoute + { + return static::match(['get'], $url, $callback, $settings); + } + + /** + * Route the given url to your callback on POST request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl + */ + public static function post(string $url, $callback, array $settings = null): IRoute + { + return static::match(['post'], $url, $callback, $settings); + } + + /** + * Route the given url to your callback on PUT request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl + */ + public static function put(string $url, $callback, array $settings = null): IRoute + { + return static::match(['put'], $url, $callback, $settings); + } + + /** + * Route the given url to your callback on PATCH request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl + */ + public static function patch(string $url, $callback, array $settings = null): IRoute + { + return static::match(['patch'], $url, $callback, $settings); + } + + /** + * Route the given url to your callback on OPTIONS request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl + */ + public static function options(string $url, $callback, array $settings = null): IRoute + { + return static::match(['options'], $url, $callback, $settings); + } + + /** + * Route the given url to your callback on DELETE request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl + */ + public static function delete(string $url, $callback, array $settings = null): IRoute + { + return static::match(['delete'], $url, $callback, $settings); + } + + /** + * Groups allows for encapsulating routes with special settings. + * + * @param array $settings + * @param \Closure $callback + * @return RouteGroup + * @throws InvalidArgumentException + */ + public static function group(array $settings, \Closure $callback): IGroupRoute + { + if (\is_callable($callback) === false) { + throw new InvalidArgumentException('Invalid callback provided. Only functions or methods supported'); + } + + $group = new RouteGroup(); + $group->setCallback($callback); + $group->setSettings($settings); + + static::router()->addRoute($group); + + return $group; + } + + /** + * Special group that has the same benefits as group but supports + * parameters and which are only rendered when the url matches. + * + * @param string $url + * @param \Closure $callback + * @param array $settings + * @return RoutePartialGroup + * @throws InvalidArgumentException + */ + public static function partialGroup(string $url, \Closure $callback, array $settings = []): IPartialGroupRoute + { + if (\is_callable($callback) === false) { + throw new InvalidArgumentException('Invalid callback provided. Only functions or methods supported'); + } + + $settings['prefix'] = $url; + + $group = new RoutePartialGroup(); + $group->setSettings($settings); + $group->setCallback($callback); + + static::router()->addRoute($group); + + return $group; + } + + /** + * Alias for the form method + * + * @param string $url + * @param callable $callback + * @param array|null $settings + * @return RouteUrl + *@see Route::form + */ + public static function basic(string $url, $callback, array $settings = null): IRoute + { + return static::match(['get', 'post'], $url, $callback, $settings); + } + + /** + * This type will route the given url to your callback on the provided request methods. + * Route the given url to your callback on POST and GET request method. + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl + *@see Route::form + */ + public static function form(string $url, $callback, array $settings = null): IRoute + { + return static::match(['get', 'post'], $url, $callback, $settings); + } + + /** + * This type will route the given url to your callback on the provided request methods. + * + * @param array $requestMethods + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl|IRoute + */ + public static function match(array $requestMethods, string $url, $callback, array $settings = null) + { + $route = new RouteUrl($url, $callback); + $route->setRequestMethods($requestMethods); + $route = static::addDefaultNamespace($route); + + if ($settings !== null) { + $route->setSettings($settings); + } + + return static::router()->addRoute($route); + } + + /** + * This type will route the given url to your callback and allow any type of request method + * + * @param string $url + * @param string|\Closure $callback + * @param array|null $settings + * @return RouteUrl|IRoute + */ + public static function all(string $url, $callback, array $settings = null) + { + $route = new RouteUrl($url, $callback); + $route = static::addDefaultNamespace($route); + + if ($settings !== null) { + $route->setSettings($settings); + } + + return static::router()->addRoute($route); + } + + /** + * This route will route request from the given url to the controller. + * + * @param string $url + * @param string $controller + * @param array|null $settings + * @return RouteController|IRoute + */ + public static function controller(string $url, $controller, array $settings = null) + { + $route = new RouteController($url, $controller); + $route = static::addDefaultNamespace($route); + + if ($settings !== null) { + $route->setSettings($settings); + } + + return static::router()->addRoute($route); + } + + /** + * This type will route all REST-supported requests to different methods in the provided controller. + * + * @param string $url + * @param string $controller + * @param array|null $settings + * @return RouteResource|IRoute + */ + public static function resource(string $url, $controller, array $settings = null) + { + $route = new RouteResource($url, $controller); + $route = static::addDefaultNamespace($route); + + if ($settings !== null) { + $route->setSettings($settings); + } + + return static::router()->addRoute($route); + } + + /** + * Add exception callback handler. + * + * @param \Closure $callback + * @return CallbackExceptionHandler $callbackHandler + */ + public static function error(\Closure $callback): CallbackExceptionHandler + { + $routes = static::router()->getRoutes(); + + $callbackHandler = new CallbackExceptionHandler($callback); + + $group = new RouteGroup(); + $group->addExceptionHandler($callbackHandler); + + array_unshift($routes, $group); + + static::router()->setRoutes($routes); + + return $callbackHandler; + } + + /** + * Get url for a route by using either name/alias, class or method name. + * + * The name parameter supports the following values: + * - Route name + * - Controller/resource name (with or without method) + * - Controller class name + * + * When searching for controller/resource by name, you can use this syntax "route.name@method". + * You can also use the same syntax when searching for a specific controller-class "MyController@home". + * If no arguments is specified, it will return the url for the current loaded route. + * + * @param string|null $name + * @param string|array|null $parameters + * @param array|null $getParams + * @return Url + */ + public static function getUrl(?string $name = null, $parameters = null, ?array $getParams = null): Url + { + try { + return static::router()->getUrl($name, $parameters, $getParams); + } catch (\Exception $e) { + try { + return new Url('/'); + } catch (MalformedUrlException $e) { + ExceptionHandler::make($e); + } + } + + // This will never happen... + return null; + } + + /** + * Get the request + * + * @return \Runtime\Http\Request + */ + public static function request(): Request + { + return static::router()->getRequest(); + } + + /** + * Get the response object + * + * @return Response + */ + public static function response(): Response + { + if (static::$response === null) { + static::$response = new Response(static::request()); + } + + return static::$response; + } + + /** + * Returns the router instance + * + * @return Router + */ + public static function router(): Router + { + if (static::$router === null) { + static::$router = new Router(); + } + + return static::$router; + } + + /** + * Prepends the default namespace to all new routes added. + * + * @param IRoute $route + * @return IRoute + */ + public static function addDefaultNamespace(IRoute $route): IRoute + { + if (static::$defaultNamespace !== null) { + + $callback = $route->getCallback(); + + /* Only add default namespace on relative callbacks */ + if ($callback === null || (\is_string($callback) === true && $callback[0] !== '\\')) { + + $namespace = static::$defaultNamespace; + + $currentNamespace = $route->getNamespace(); + + if ($currentNamespace !== null) { + $namespace .= '\\' . $currentNamespace; + } + + $route->setDefaultNamespace($namespace); + + } + } + + return $route; + } + + /** + * Enable or disable dependency injection + * + * @param Container $container + * @return IClassLoader + */ + public static function enableDependencyInjection(Container $container): IClassLoader + { + return static::router() + ->getClassLoader() + ->useDependencyInjection(true) + ->setContainer($container); + } + + /** + * Get default namespace + * @return string|null + */ + public static function getDefaultNamespace(): ?string + { + return static::$defaultNamespace; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/IControllerRoute.php b/src/Runtime/Router/Route/IControllerRoute.php new file mode 100644 index 0000000..abc2db5 --- /dev/null +++ b/src/Runtime/Router/Route/IControllerRoute.php @@ -0,0 +1,22 @@ +debug('Loading middlewares'); + + foreach ($this->getMiddlewares() as $middleware) { + + if (\is_object($middleware) === false) { + $middleware = $router->getClassLoader()->loadClass($middleware); + } + + if (($middleware instanceof IMiddleware) === false) { + throw new HttpException($middleware . ' must be inherit the IMiddleware interface'); + } + + $className = \get_class($middleware); + + $router->debug('Loading middleware "%s"', $className); + $middleware->handle($request); + $router->debug('Finished loading middleware "%s"', $className); + } + + $router->debug('Finished loading middlewares'); + } + + public function matchRegex(Request $request, $url): ?bool + { + /* Match on custom defined regular expression */ + + if ($this->regex === null) { + return null; + } + + return ((bool)preg_match($this->regex, $request->getHost() . $url) !== false); + } + + /** + * Set url + * + * @param string $url + * @return static + */ + public function setUrl(string $url): ILoadableRoute + { + $this->url = ($url === '/') ? '/' : '/' . trim($url, '/') . '/'; + + if (strpos($this->url, $this->paramModifiers[0]) !== false) { + + $regex = sprintf(static::PARAMETERS_REGEX_FORMAT, $this->paramModifiers[0], $this->paramOptionalSymbol, $this->paramModifiers[1]); + + if ((bool)preg_match_all('/' . $regex . '/u', $this->url, $matches) !== false) { + $this->parameters = array_fill_keys($matches[1], null); + } + } + + return $this; + } + + /** + * Prepend url + * + * @param string $url + * @return ILoadableRoute + */ + public function prependUrl(string $url): ILoadableRoute + { + return $this->setUrl(rtrim($url, '/') . $this->url); + } + + public function getUrl(): string + { + return $this->url; + } + + /** + * Find url that matches method, parameters or name. + * Used when calling the url() helper. + * + * @param string|null $method + * @param string|array|null $parameters + * @param string|null $name + * @return string + */ + public function findUrl(?string $method = null, $parameters = null, ?string $name = null): string + { + $url = $this->getUrl(); + + $group = $this->getGroup(); + + if ($group !== null && \count($group->getDomains()) !== 0) { + $url = '//' . $group->getDomains()[0] . $url; + } + + /* Create the param string - {parameter} */ + $param1 = $this->paramModifiers[0] . '%s' . $this->paramModifiers[1]; + + /* Create the param string with the optional symbol - {parameter?} */ + $param2 = $this->paramModifiers[0] . '%s' . $this->paramOptionalSymbol . $this->paramModifiers[1]; + + /* Replace any {parameter} in the url with the correct value */ + + $params = $this->getParameters(); + + foreach (array_keys($params) as $param) { + + if ($parameters === '' || (\is_array($parameters) === true && \count($parameters) === 0)) { + $value = ''; + } else { + $p = (array)$parameters; + $value = array_key_exists($param, $p) ? $p[$param] : $params[$param]; + + /* If parameter is specifically set to null - use the original-defined value */ + if ($value === null && isset($this->originalParameters[$param]) === true) { + $value = $this->originalParameters[$param]; + } + } + + if (stripos($url, $param1) !== false || stripos($url, $param) !== false) { + /* Add parameter to the correct position */ + $url = str_ireplace([sprintf($param1, $param), sprintf($param2, $param)], $value, $url); + } else { + /* Parameter aren't recognized and will be appended at the end of the url */ + $url .= $value . '/'; + } + } + + return rtrim('/' . ltrim($url, '/'), '/') . '/'; + } + + /** + * Returns the provided name for the router. + * + * @return string + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Check if route has given name. + * + * @param string $name + * @return bool + */ + public function hasName(string $name): bool + { + return strtolower($this->name) === strtolower($name); + } + + /** + * Add regular expression match for the entire route. + * + * @param string $regex + * @return static + */ + public function setMatch($regex): ILoadableRoute + { + $this->regex = $regex; + + return $this; + } + + /** + * Get regular expression match used for matching route (if defined). + * + * @return string + */ + public function getMatch(): string + { + return $this->regex; + } + + /** + * Sets the router name, which makes it easier to obtain the url or router at a later point. + * Alias for LoadableRoute::setName(). + * + * @see LoadableRoute::setName() + * @param string|array $name + * @return static + */ + public function name($name): ILoadableRoute + { + return $this->setName($name); + } + + /** + * Sets the router name, which makes it easier to obtain the url or router at a later point. + * + * @param string $name + * @return static + */ + public function setName(string $name): ILoadableRoute + { + $this->name = $name; + + return $this; + } + + /** + * Merge with information from another route. + * + * @param array $values + * @param bool $merge + * @return static + */ + public function setSettings(array $values, bool $merge = false): IRoute + { + if (isset($values['as']) === true) { + + $name = $values['as']; + + if ($this->name !== null && $merge !== false) { + $name .= '.' . $this->name; + } + + $this->setName($name); + } + + if (isset($values['prefix']) === true) { + $this->prependUrl($values['prefix']); + } + + return parent::setSettings($values, $merge); + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/Route.php b/src/Runtime/Router/Route/Route.php new file mode 100644 index 0000000..a855de8 --- /dev/null +++ b/src/Runtime/Router/Route/Route.php @@ -0,0 +1,580 @@ +debug('Starting rendering route "%s"', \get_class($this)); + + $callback = $this->getCallback(); + + if ($callback === null) { + return null; + } + + $router->debug('Parsing parameters'); + + $parameters = $this->getParameters(); + + $router->debug('Finished parsing parameters'); + + /* Filter parameters with null-value */ + if ($this->filterEmptyParams === true) { + $parameters = array_filter($parameters, function ($var) { + return ($var !== null); + }); + } + + /* Render callback function */ + if (\is_callable($callback) === true) { + $router->debug('Executing callback'); + + /* When the callback is a function */ + + return $router->getClassLoader()->loadClosure($callback, $parameters); + } + + /* When the callback is a class + method */ + $controller = explode('@', $callback); + + $namespace = $this->getNamespace(); + + $className = ($namespace !== null && $controller[0][0] !== '\\') ? $namespace . '\\' . $controller[0] : $controller[0]; + + $router->debug('Loading class %s', $className); + $class = $router->getClassLoader()->loadClass($className); + + if (\count($controller) === 1) { + $controller[1] = '__invoke'; + } + + $method = $controller[1]; + + if (method_exists($class, $method) === false) { + throw new NotFoundHttpException(sprintf('Method "%s" does not exist in class "%s"', $method, $className), 404); + } + + $router->debug('Executing callback'); + + return \call_user_func_array([$class, $method], $parameters); + } + + protected function parseParameters($route, $url, $parameterRegex = null) + { + $regex = (strpos($route, $this->paramModifiers[0]) === false) ? null : + sprintf + ( + static::PARAMETERS_REGEX_FORMAT, + $this->paramModifiers[0], + $this->paramOptionalSymbol, + $this->paramModifiers[1] + ); + + // Ensures that host names/domains will work with parameters + $url = '/' . ltrim($url, '/'); + $urlRegex = ''; + $parameters = []; + + if ($regex === null || (bool)preg_match_all('/' . $regex . '/u', $route, $parameters) === false) { + $urlRegex = preg_quote($route, '/'); + } else { + + foreach (preg_split('/((\-?\/?)\{[^}]+\})/', $route) as $key => $t) { + + $regex = ''; + + if ($key < \count($parameters[1])) { + + $name = $parameters[1][$key]; + + /* If custom regex is defined, use that */ + if (isset($this->where[$name]) === true) { + $regex = $this->where[$name]; + } else if ($parameterRegex !== null) { + $regex = $parameterRegex; + } else { + $regex = $this->defaultParameterRegex ?? static::PARAMETERS_DEFAULT_REGEX; + } + + $regex = sprintf('((\/|\-)(?P<%2$s>%3$s))%1$s', $parameters[2][$key], $name, $regex); + } + + $urlRegex .= preg_quote($t, '/') . $regex; + } + } + + if (trim($urlRegex) === '' || (bool)preg_match(sprintf($this->urlRegex, $urlRegex), $url, $matches) === false) { + return null; + } + + $values = []; + + if (isset($parameters[1]) === true) { + + /* Only take matched parameters with name */ + foreach ((array)$parameters[1] as $name) { + $values[$name] = (isset($matches[$name]) === true && $matches[$name] !== '') ? $matches[$name] : null; + } + } + + return $values; + } + + /** + * Returns callback name/identifier for the current route based on the callback. + * Useful if you need to get a unique identifier for the loaded route, for instance + * when using translations etc. + * + * @return string + */ + public function getIdentifier(): string + { + if (\is_string($this->callback) === true && strpos($this->callback, '@') !== false) { + return $this->callback; + } + + return 'function:' . md5($this->callback); + } + + /** + * Set allowed request methods + * + * @param array $methods + * @return static + */ + public function setRequestMethods(array $methods): IRoute + { + $this->requestMethods = $methods; + + return $this; + } + + /** + * Get allowed request methods + * + * @return array + */ + public function getRequestMethods(): array + { + return $this->requestMethods; + } + + /** + * @return IRoute|null + */ + public function getParent(): ?IRoute + { + return $this->parent; + } + + /** + * Get the group for the route. + * + * @return IGroupRoute|null + */ + public function getGroup(): ?IGroupRoute + { + return $this->group; + } + + /** + * Set group + * + * @param IGroupRoute $group + * @return static + */ + public function setGroup(IGroupRoute $group): IRoute + { + $this->group = $group; + + /* Add/merge parent settings with child */ + + return $this->setSettings($group->toArray(), true); + } + + /** + * Set parent route + * + * @param IRoute $parent + * @return static + */ + public function setParent(IRoute $parent): IRoute + { + $this->parent = $parent; + + return $this; + } + + /** + * Set callback + * + * @param string $callback + * @return static + */ + public function setCallback($callback): IRoute + { + $this->callback = $callback; + + return $this; + } + + /** + * @return string|callable + */ + public function getCallback() + { + return $this->callback; + } + + public function getMethod(): ?string + { + if (\is_string($this->callback) === true && strpos($this->callback, '@') !== false) { + $tmp = explode('@', $this->callback); + + return $tmp[1]; + } + + return null; + } + + public function getClass(): ?string + { + if (\is_string($this->callback) === true && strpos($this->callback, '@') !== false) { + $tmp = explode('@', $this->callback); + + return $tmp[0]; + } + + return null; + } + + public function setMethod(string $method): IRoute + { + $this->callback = sprintf('%s@%s', $this->getClass(), $method); + + return $this; + } + + public function setClass(string $class): IRoute + { + $this->callback = sprintf('%s@%s', $class, $this->getMethod()); + + return $this; + } + + /** + * @param string $namespace + * @return static + */ + public function setNamespace(string $namespace): IRoute + { + $this->namespace = $namespace; + + return $this; + } + + /** + * @param string $namespace + * @return static + */ + public function setDefaultNamespace($namespace): IRoute + { + $this->defaultNamespace = $namespace; + + return $this; + } + + public function getDefaultNamespace(): ?string + { + return $this->defaultNamespace; + } + + /** + * @return string|null + */ + public function getNamespace(): ?string + { + return $this->namespace ?? $this->defaultNamespace; + } + + /** + * Export route settings to array so they can be merged with another route. + * + * @return array + */ + public function toArray(): array + { + $values = []; + + if ($this->namespace !== null) { + $values['namespace'] = $this->namespace; + } + + if (\count($this->requestMethods) !== 0) { + $values['method'] = $this->requestMethods; + } + + if (\count($this->where) !== 0) { + $values['where'] = $this->where; + } + + if (\count($this->middlewares) !== 0) { + $values['middleware'] = $this->middlewares; + } + + if ($this->defaultParameterRegex !== null) { + $values['defaultParameterRegex'] = $this->defaultParameterRegex; + } + + return $values; + } + + /** + * Merge with information from another route. + * + * @param array $values + * @param bool $merge + * @return static + */ + public function setSettings(array $values, bool $merge = false): IRoute + { + if ($this->namespace === null && isset($values['namespace']) === true) { + $this->setNamespace($values['namespace']); + } + + if (isset($values['method']) === true) { + $this->setRequestMethods(array_merge($this->requestMethods, (array)$values['method'])); + } + + if (isset($values['where']) === true) { + $this->setWhere(array_merge($this->where, (array)$values['where'])); + } + + if (isset($values['parameters']) === true) { + $this->setParameters(array_merge($this->parameters, (array)$values['parameters'])); + } + + // Push middleware if multiple + if (isset($values['middleware']) === true) { + $this->setMiddlewares(array_merge((array)$values['middleware'], $this->middlewares)); + } + + if (isset($values['defaultParameterRegex']) === true) { + $this->setDefaultParameterRegex($values['defaultParameterRegex']); + } + + return $this; + } + + /** + * Get parameter names. + * + * @return array + */ + public function getWhere(): array + { + return $this->where; + } + + /** + * Set parameter names. + * + * @param array $options + * @return static + */ + public function setWhere(array $options): IRoute + { + $this->where = $options; + + return $this; + } + + /** + * Add regular expression parameter match. + * Alias for LoadableRoute::where() + * + * @see LoadableRoute::where() + * @param array $options + * @return static + */ + public function where(array $options) + { + return $this->setWhere($options); + } + + /** + * Get parameters + * + * @return array + */ + public function getParameters(): array + { + /* Sort the parameters after the user-defined param order, if any */ + $parameters = []; + + if (\count($this->originalParameters) !== 0) { + $parameters = $this->originalParameters; + } + + return array_merge($parameters, $this->parameters); + } + + /** + * Get parameters + * + * @param array $parameters + * @return static + */ + public function setParameters(array $parameters): IRoute + { + /* + * If this is the first time setting parameters we store them so we + * later can organize the array, in case somebody tried to sort the array. + */ + if (\count($parameters) !== 0 && \count($this->originalParameters) === 0) { + $this->originalParameters = $parameters; + } + + $this->parameters = array_merge($this->parameters, $parameters); + + return $this; + } + + /** + * Add middleware class-name + * + * @deprecated This method is deprecated and will be removed in the near future. + * @param IMiddleware|string $middleware + * @return static + */ + public function setMiddleware($middleware) + { + $this->middlewares[] = $middleware; + + return $this; + } + + /** + * Add middleware class-name + * + * @param IMiddleware|string $middleware + * @return static + */ + public function addMiddleware($middleware): IRoute + { + $this->middlewares[] = $middleware; + + return $this; + } + + /** + * Set middlewares array + * + * @param array $middlewares + * @return static + */ + public function setMiddlewares(array $middlewares): IRoute + { + $this->middlewares = $middlewares; + + return $this; + } + + /** + * @return array + */ + public function getMiddlewares(): array + { + return $this->middlewares; + } + + /** + * Set default regular expression used when matching parameters. + * This is used when no custom parameter regex is found. + * + * @param string $regex + * @return static + */ + public function setDefaultParameterRegex($regex) + { + $this->defaultParameterRegex = $regex; + + return $this; + } + + /** + * Get default regular expression used when matching parameters. + * + * @return string + */ + public function getDefaultParameterRegex(): string + { + return $this->defaultParameterRegex; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/RouteController.php b/src/Runtime/Router/Route/RouteController.php new file mode 100644 index 0000000..d412224 --- /dev/null +++ b/src/Runtime/Router/Route/RouteController.php @@ -0,0 +1,183 @@ +setUrl($url); + $this->setName(trim(str_replace('/', '.', $url), '/')); + $this->controller = $controller; + } + + /** + * Check if route has given name. + * + * @param string $name + * @return bool + */ + public function hasName(string $name): bool + { + if ($this->name === null) { + return false; + } + + /* Remove method/type */ + if (strpos($name, '.') !== false) { + $method = substr($name, strrpos($name, '.') + 1); + $newName = substr($name, 0, strrpos($name, '.')); + + if (\in_array($method, $this->names, true) === true && strtolower($this->name) === strtolower($newName)) { + return true; + } + } + + return parent::hasName($name); + } + + /** + * @param string|null $method + * @param string|array|null $parameters + * @param string|null $name + * @return string + */ + public function findUrl(?string $method = null, $parameters = null, ?string $name = null): string + { + if (strpos($name, '.') !== false) { + $found = array_search(substr($name, strrpos($name, '.') + 1), $this->names, false); + if ($found !== false) { + $method = (string)$found; + } + } + + $url = ''; + $parameters = (array)$parameters; + + if ($method !== null) { + + /* Remove requestType from method-name, if it exists */ + foreach (static::$requestTypes as $requestType) { + + if (stripos($method, $requestType) === 0) { + $method = (string)substr($method, \strlen($requestType)); + break; + } + } + + $method .= '/'; + } + + $group = $this->getGroup(); + + if ($group !== null && \count($group->getDomains()) !== 0) { + $url .= '//' . $group->getDomains()[0]; + } + + $url .= '/' . trim($this->getUrl(), '/') . '/' . strtolower($method) . implode('/', $parameters); + + return '/' . trim($url, '/') . '/'; + } + + public function matchRoute($url, Request $request): bool + { + if ($this->getGroup() !== null && $this->getGroup()->matchRoute($url, $request) === false) { + return false; + } + + /* Match global regular-expression for route */ + $regexMatch = $this->matchRegex($request, $url); + + if ($regexMatch === false || (stripos($url, $this->url) !== 0 && strtoupper($url) !== strtoupper($this->url))) { + return false; + } + + $strippedUrl = trim(str_ireplace($this->url, '/', $url), '/'); + $path = explode('/', $strippedUrl); + + if (\count($path) !== 0) { + + $method = (isset($path[0]) === false || trim($path[0]) === '') ? $this->defaultMethod : $path[0]; + $this->method = $request->getMethod() . ucfirst($method); + + $this->parameters = \array_slice($path, 1); + + // Set callback + $this->setCallback($this->controller . '@' . $this->method); + + return true; + } + + return false; + } + + /** + * Get controller class-name. + * + * @return string + */ + public function getController(): string + { + return $this->controller; + } + + /** + * Get controller class-name. + * + * @param string $controller + * @return static + */ + public function setController(string $controller): IControllerRoute + { + $this->controller = $controller; + + return $this; + } + + /** + * Return active method + * + * @return string|null + */ + public function getMethod(): ?string + { + return $this->method; + } + + /** + * Set active method + * + * @param string $method + * @return static + */ + public function setMethod(string $method): IRoute + { + $this->method = $method; + + return $this; + } + + /** + * Merge with information from another route. + * + * @param array $values + * @param bool $merge + * @return static + */ + public function setSettings(array $values, bool $merge = false): IRoute + { + if (isset($values['names']) === true) { + $this->names = $values['names']; + } + + return parent::setSettings($values, $merge); + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/RouteGroup.php b/src/Runtime/Router/Route/RouteGroup.php new file mode 100644 index 0000000..c5c9b2a --- /dev/null +++ b/src/Runtime/Router/Route/RouteGroup.php @@ -0,0 +1,203 @@ +domains === null || \count($this->domains) === 0) { + return true; + } + + foreach ($this->domains as $domain) { + + $parameters = $this->parseParameters($domain, $request->getHost(), '.*'); + + if ($parameters !== null && \count($parameters) !== 0) { + + $this->parameters = $parameters; + + return true; + } + } + + return false; + } + + /** + * Method called to check if route matches + * + * @param string $url + * @param Request $request + * @return bool + */ + public function matchRoute($url, Request $request): bool + { + if ($this->getGroup() !== null && $this->getGroup()->matchRoute($url, $request) === false) { + return false; + } + + /* Skip if prefix doesn't match */ + if ($this->prefix !== null && stripos($url, $this->prefix) === false) { + return false; + } + + return $this->matchDomain($request); + } + + /** + * Add exception handler + * + * @param IExceptionHandler|string $handler + * @return static + */ + public function addExceptionHandler($handler): IGroupRoute + { + $this->exceptionHandlers[] = $handler; + + return $this; + } + + /** + * Set exception-handlers for group + * + * @param array $handlers + * @return static + */ + public function setExceptionHandlers(array $handlers): IGroupRoute + { + $this->exceptionHandlers = $handlers; + + return $this; + } + + /** + * Get exception-handlers for group + * + * @return array + */ + public function getExceptionHandlers(): array + { + return $this->exceptionHandlers; + } + + /** + * Get allowed domains for domain. + * + * @return array + */ + public function getDomains(): array + { + return $this->domains; + } + + /** + * Set allowed domains for group. + * + * @param array $domains + * @return static + */ + public function setDomains(array $domains): IGroupRoute + { + $this->domains = $domains; + + return $this; + } + + /** + * @param string $prefix + * @return static + */ + public function setPrefix($prefix): IGroupRoute + { + $this->prefix = '/' . trim($prefix, '/'); + + return $this; + } + + /** + * Set prefix that child-routes will inherit. + * + * @return string|null + */ + public function getPrefix(): ?string + { + return $this->prefix; + } + + /** + * Merge with information from another route. + * + * @param array $values + * @param bool $merge + * @return static + */ + public function setSettings(array $values, bool $merge = false): IRoute + { + + if (isset($values['prefix']) === true) { + $this->setPrefix($values['prefix'] . $this->prefix); + } + + if ($merge === false && isset($values['exceptionHandler']) === true) { + $this->setExceptionHandlers((array)$values['exceptionHandler']); + } + + if ($merge === false && isset($values['domain']) === true) { + $this->setDomains((array)$values['domain']); + } + + if (isset($values['as']) === true) { + + $name = $values['as']; + + if ($this->name !== null && $merge !== false) { + $name .= '.' . $this->name; + } + + $this->name = $name; + } + + return parent::setSettings($values, $merge); + } + + /** + * Export route settings to array so they can be merged with another route. + * + * @return array + */ + public function toArray(): array + { + $values = []; + + if ($this->prefix !== null) { + $values['prefix'] = $this->getPrefix(); + } + + if ($this->name !== null) { + $values['as'] = $this->name; + } + + if (\count($this->parameters) !== 0) { + $values['parameters'] = $this->parameters; + } + + return array_merge($values, parent::toArray()); + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/RoutePartialGroup.php b/src/Runtime/Router/Route/RoutePartialGroup.php new file mode 100644 index 0000000..d3e612c --- /dev/null +++ b/src/Runtime/Router/Route/RoutePartialGroup.php @@ -0,0 +1,47 @@ +urlRegex = '/^%s\/?/u'; + } + + /** + * Method called to check if route matches + * + * @param string $url + * @param Request $request + * @return bool + */ + public function matchRoute($url, Request $request): bool + { + if ($this->getGroup() !== null && $this->getGroup()->matchRoute($url, $request) === false) { + return false; + } + + if ($this->prefix !== null) { + /* Parse parameters from current route */ + $parameters = $this->parseParameters($this->prefix, $url); + + /* If no custom regular expression or parameters was found on this route, we stop */ + if ($parameters === null) { + return false; + } + + /* Set the parameters */ + $this->setParameters((array)$parameters); + } + + return $this->matchDomain($request); + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/RouteResource.php b/src/Runtime/Router/Route/RouteResource.php new file mode 100644 index 0000000..db8f856 --- /dev/null +++ b/src/Runtime/Router/Route/RouteResource.php @@ -0,0 +1,230 @@ + '', + 'create' => 'create', + 'store' => '', + 'show' => '', + 'edit' => 'edit', + 'update' => '', + 'destroy' => '', + ]; + + protected $methodNames = [ + 'index' => 'index', + 'create' => 'create', + 'store' => 'store', + 'show' => 'show', + 'edit' => 'edit', + 'update' => 'update', + 'destroy' => 'destroy', + ]; + + protected $names = []; + protected $controller; + + public function __construct($url, $controller) + { + $this->setUrl($url); + $this->controller = $controller; + $this->setName(trim(str_replace('/', '.', $url), '/')); + } + + /** + * Check if route has given name. + * + * @param string $name + * @return bool + */ + public function hasName(string $name): bool + { + if ($this->name === null) { + return false; + } + + if (strtolower($this->name) === strtolower($name)) { + return true; + } + + /* Remove method/type */ + if (strpos($name, '.') !== false) { + $name = (string)substr($name, 0, strrpos($name, '.')); + } + + return (strtolower($this->name) === strtolower($name)); + } + + /** + * @param string|null $method + * @param array|string|null $parameters + * @param string|null $name + * @return string + */ + public function findUrl(?string $method = null, $parameters = null, ?string $name = null): string + { + $url = array_search($name, $this->names, false); + if ($url !== false) { + return rtrim($this->url . $this->urls[$url], '/') . '/'; + } + + return $this->url; + } + + protected function call($method) + { + $this->setCallback($this->controller . '@' . $method); + + return true; + } + + public function matchRoute($url, Request $request): bool + { + if ($this->getGroup() !== null && $this->getGroup()->matchRoute($url, $request) === false) { + return false; + } + + /* Match global regular-expression for route */ + $regexMatch = $this->matchRegex($request, $url); + + if ($regexMatch === false || (stripos($url, $this->url) !== 0 && strtoupper($url) !== strtoupper($this->url))) { + return false; + } + + $route = rtrim($this->url, '/') . '/{id?}/{action?}'; + + /* Parse parameters from current route */ + $this->parameters = $this->parseParameters($route, $url); + + /* If no custom regular expression or parameters was found on this route, we stop */ + if ($regexMatch === null && $this->parameters === null) { + return false; + } + + $action = strtolower(trim($this->parameters['action'])); + $id = $this->parameters['id']; + + // Remove action parameter + unset($this->parameters['action']); + + $method = $request->getMethod(); + + // Delete + if ($method === static::REQUEST_TYPE_DELETE && $id !== null) { + return $this->call($this->methodNames['destroy']); + } + + // Update + if ($id !== null && \in_array($method, [static::REQUEST_TYPE_PATCH, static::REQUEST_TYPE_PUT], true) === true) { + return $this->call($this->methodNames['update']); + } + + // Edit + if ($method === static::REQUEST_TYPE_GET && $id !== null && $action === 'edit') { + return $this->call($this->methodNames['edit']); + } + + // Create + if ($method === static::REQUEST_TYPE_GET && $id === 'create') { + return $this->call($this->methodNames['create']); + } + + // Save + if ($method === static::REQUEST_TYPE_POST) { + return $this->call($this->methodNames['store']); + } + + // Show + if ($method === static::REQUEST_TYPE_GET && $id !== null) { + return $this->call($this->methodNames['show']); + } + + // Index + return $this->call($this->methodNames['index']); + } + + /** + * @return string + */ + public function getController(): string + { + return $this->controller; + } + + /** + * @param string $controller + * @return static + */ + public function setController(string $controller): IControllerRoute + { + $this->controller = $controller; + + return $this; + } + + public function setName(string $name): ILoadableRoute + { + $this->name = $name; + + $this->names = [ + 'index' => $this->name . '.index', + 'create' => $this->name . '.create', + 'store' => $this->name . '.store', + 'show' => $this->name . '.show', + 'edit' => $this->name . '.edit', + 'update' => $this->name . '.update', + 'destroy' => $this->name . '.destroy', + ]; + + return $this; + } + + /** + * Define custom method name for resource controller + * + * @param array $names + * @return static $this + */ + public function setMethodNames(array $names) + { + $this->methodNames = $names; + + return $this; + } + + /** + * Get method names + * + * @return array + */ + public function getMethodNames(): array + { + return $this->methodNames; + } + + /** + * Merge with information from another route. + * + * @param array $values + * @param bool $merge + * @return static + */ + public function setSettings(array $values, bool $merge = false): IRoute + { + if (isset($values['names']) === true) { + $this->names = $values['names']; + } + + if (isset($values['methods']) === true) { + $this->methodNames = $values['methods']; + } + + return parent::setSettings($values, $merge); + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Route/RouteUrl.php b/src/Runtime/Router/Route/RouteUrl.php new file mode 100644 index 0000000..5944f71 --- /dev/null +++ b/src/Runtime/Router/Route/RouteUrl.php @@ -0,0 +1,42 @@ +setUrl($url); + $this->setCallback($callback); + } + + public function matchRoute($url, Request $request): bool + { + if ($this->getGroup() !== null && $this->getGroup()->matchRoute($url, $request) === false) { + return false; + } + + /* Match global regular-expression for route */ + $regexMatch = $this->matchRegex($request, $url); + + if ($regexMatch === false) { + return false; + } + + /* Parse parameters from current route */ + $parameters = $this->parseParameters($this->url, $url); + + /* If no custom regular expression or parameters was found on this route, we stop */ + if ($regexMatch === null && $parameters === null) { + return false; + } + + /* Set the parameters */ + $this->setParameters((array)$parameters); + + return true; + } + +} \ No newline at end of file diff --git a/src/Runtime/Router/Router.php b/src/Runtime/Router/Router.php new file mode 100644 index 0000000..9ca968a --- /dev/null +++ b/src/Runtime/Router/Router.php @@ -0,0 +1,911 @@ +reset(); + } + + /** + * Resets the router by reloading request and clearing all routes and data. + */ + public function reset(): void + { + $this->debugStartTime = microtime(true); + $this->isProcessingRoute = false; + + try { + $this->request = new Request(); + } catch (MalformedUrlException $e) { + $this->debug(sprintf('Invalid request-uri url: %s', $e->getMessage())); + } + + $this->routes = []; + $this->bootManagers = []; + $this->routeStack = []; + $this->processedRoutes = []; + $this->exceptionHandlers = []; + $this->loadedExceptionHandlers = []; + $this->eventHandlers = []; + $this->debugList = []; + $this->csrfVerifier = null; + $this->classLoader = new ClassLoader(); + } + + /** + * Add route + * @param IRoute $route + * @return IRoute + */ + public function addRoute(IRoute $route): IRoute + { + $this->fireEvents(EventHandler::EVENT_ADD_ROUTE, [ + 'route' => $route, + ]); + + /* + * If a route is currently being processed, that means that the route being added are rendered from the parent + * routes callback, so we add them to the stack instead. + */ + if ($this->isProcessingRoute === true) { + $this->routeStack[] = $route; + } else { + $this->routes[] = $route; + } + + return $route; + } + + /** + * Render and process any new routes added. + * + * @param IRoute $route + * @throws NotFoundHttpException + */ + protected function renderAndProcess(IRoute $route): void + { + + $this->isProcessingRoute = true; + $route->renderRoute($this->request, $this); + $this->isProcessingRoute = false; + + if (\count($this->routeStack) !== 0) { + + /* Pop and grab the routes added when executing group callback earlier */ + $stack = $this->routeStack; + $this->routeStack = []; + + /* Route any routes added to the stack */ + $this->processRoutes($stack, ($route instanceof IGroupRoute) ? $route : null); + } + } + + /** + * Process added routes. + * + * @param array $routes + * @param IGroupRoute|null $group + * @throws NotFoundHttpException + */ + protected function processRoutes(array $routes, ?IGroupRoute $group = null): void + { + $this->debug('Processing routes'); + + // Loop through each route-request + $exceptionHandlers = []; + + // Stop processing routes if no valid route is found. + if ($this->request->getRewriteRoute() === null && $this->request->getUrl() === null) { + $this->debug('Halted route-processing as no valid route was found'); + + return; + } + + $url = $this->request->getRewriteUrl() ?? $this->request->getUrl()->getPath(); + + /* @var $route IRoute */ + foreach ($routes as $route) { + + $this->debug('Processing route "%s"', \get_class($route)); + + if ($group !== null) { + /* Add the parent group */ + $route->setGroup($group); + } + + /* @var $route IGroupRoute */ + if ($route instanceof IGroupRoute) { + + if ($route->matchRoute($url, $this->request) === true) { + + /* Add exception handlers */ + if (\count($route->getExceptionHandlers()) !== 0) { + /** @noinspection AdditionOperationOnArraysInspection */ + $exceptionHandlers += $route->getExceptionHandlers(); + } + + /* Only render partial group if it matches */ + if ($route instanceof IPartialGroupRoute === true) { + $this->renderAndProcess($route); + } + + } + + if ($route instanceof IPartialGroupRoute === false) { + $this->renderAndProcess($route); + } + + continue; + } + + if ($route instanceof ILoadableRoute === true) { + + /* Add the route to the map, so we can find the active one when all routes has been loaded */ + $this->processedRoutes[] = $route; + } + } + + $this->exceptionHandlers = array_merge($exceptionHandlers, $this->exceptionHandlers); + } + + /** + * Load routes + * @throws NotFoundHttpException + * @return void + */ + public function loadRoutes(): void + { + $this->debug('Loading routes'); + + $this->fireEvents(EventHandler::EVENT_BOOT, [ + 'bootmanagers' => $this->bootManagers, + ]); + + /* Initialize boot-managers */ + + /* @var $manager IRouterBootManager */ + foreach ($this->bootManagers as $manager) { + + $className = \get_class($manager); + $this->debug('Rendering bootmanager "%s"', $className); + $this->fireEvents(EventHandler::EVENT_RENDER_BOOTMANAGER, [ + 'bootmanagers' => $this->bootManagers, + 'bootmanager' => $manager, + ]); + + /* Render bootmanager */ + $manager->boot($this, $this->request); + + $this->debug('Finished rendering bootmanager "%s"', $className); + } + + $this->fireEvents(EventHandler::EVENT_LOAD_ROUTES, [ + 'routes' => $this->routes, + ]); + + /* Loop through each route-request */ + $this->processRoutes($this->routes); + + $this->debug('Finished loading routes'); + } + + /** + * Start the routing + * + * @return string|null + * @throws \Runtime\Router\Exceptions\NotFoundHttpException + * @throws \Runtime\Exceptions\TokenMismatchException + * @throws HttpException + * @throws \Exception + */ + public function start(): ?string + { + $this->debug('Router starting'); + + $this->fireEvents(EventHandler::EVENT_INIT); + + $this->loadRoutes(); + + if ($this->csrfVerifier !== null) { + + $this->fireEvents(EventHandler::EVENT_RENDER_CSRF, [ + 'csrfVerifier' => $this->csrfVerifier, + ]); + + /* Verify csrf token for request */ + $this->csrfVerifier->handle($this->request); + } + + $output = $this->routeRequest(); + + $this->fireEvents(EventHandler::EVENT_LOAD, [ + 'loadedRoutes' => $this->getRequest()->getLoadedRoutes(), + ]); + + $this->debug('Router complete'); + + return $output; + } + + /** + * Routes the request + * + * @return string|null + * @throws HttpException + * @throws \Exception + */ + public function routeRequest(): ?string + { + $this->debug('Router request'); + + $methodNotAllowed = false; + + try { + $url = $this->request->getRewriteUrl() ?? $this->request->getUrl()->getPath(); + + /* @var $route ILoadableRoute */ + foreach ($this->processedRoutes as $key => $route) { + + $this->debug('Matching route "%s"', \get_class($route)); + + /* If the route matches */ + if ($route->matchRoute($url, $this->request) === true) { + + $this->fireEvents(EventHandler::EVENT_MATCH_ROUTE, [ + 'route' => $route, + ]); + + /* Check if request method matches */ + if (\count($route->getRequestMethods()) !== 0 && \in_array($this->request->getMethod(), $route->getRequestMethods(), true) === false) { + $this->debug('Method "%s" not allowed', $this->request->getMethod()); + $methodNotAllowed = true; + continue; + } + + $this->fireEvents(EventHandler::EVENT_RENDER_MIDDLEWARES, [ + 'route' => $route, + 'middlewares' => $route->getMiddlewares(), + ]); + + $route->loadMiddleware($this->request, $this); + + $output = $this->handleRouteRewrite($key, $url); + if ($output !== null) { + return $output; + } + + $methodNotAllowed = false; + + $this->request->addLoadedRoute($route); + + $this->fireEvents(EventHandler::EVENT_RENDER_ROUTE, [ + 'route' => $route, + ]); + + $output = $route->renderRoute($this->request, $this); + if ($output !== null) { + return $output; + } + + $output = $this->handleRouteRewrite($key, $url); + if ($output !== null) { + return $output; + } + } + } + + } catch (\Exception $e) { + $this->handleException($e); + } + + if ($methodNotAllowed === true) { + $message = sprintf('Route "%s" or method "%s" not allowed.', $this->request->getUrl()->getPath(), $this->request->getMethod()); + $this->handleException(new NotFoundHttpException($message, 403)); + } + + if (\count($this->request->getLoadedRoutes()) === 0) { + + $rewriteUrl = $this->request->getRewriteUrl(); + + if ($rewriteUrl !== null) { + $message = sprintf('Route not found: "%s" (rewrite from: "%s")', $rewriteUrl, $this->request->getUrl()->getPath()); + } else { + $message = sprintf('Route not found: "%s"', $this->request->getUrl()->getPath()); + } + + $this->debug($message); + + return $this->handleException(new NotFoundHttpException($message, 404)); + } + + return null; + } + + /** + * Handle route-rewrite + * + * @param string $key + * @param string $url + * @return string|null + * @throws HttpException + * @throws \Exception + */ + protected function handleRouteRewrite($key, string $url): ?string + { + /* If the request has changed */ + if ($this->request->hasPendingRewrite() === false) { + return null; + } + + $route = $this->request->getRewriteRoute(); + + if ($route !== null) { + /* Add rewrite route */ + $this->processedRoutes[] = $route; + } + + if ($this->request->getRewriteUrl() !== $url) { + + unset($this->processedRoutes[$key]); + + $this->request->setHasPendingRewrite(false); + + $this->fireEvents(EventHandler::EVENT_REWRITE, [ + 'rewriteUrl' => $this->request->getRewriteUrl(), + 'rewriteRoute' => $this->request->getRewriteRoute(), + ]); + + return $this->routeRequest(); + } + + return null; + } + + /** + * @param \Exception $e + * @throws HttpException + * @throws \Exception + * @return string|null + */ + protected function handleException(\Exception $e): ?string + { + $this->debug('Starting exception handling for "%s"', \get_class($e)); + + $this->fireEvents(EventHandler::EVENT_LOAD_EXCEPTIONS, [ + 'exception' => $e, + 'exceptionHandlers' => $this->exceptionHandlers, + ]); + + /* @var $handler IExceptionHandler */ + foreach ($this->exceptionHandlers as $key => $handler) { + + if (\is_object($handler) === false) { + $handler = new $handler(); + } + + $this->fireEvents(EventHandler::EVENT_RENDER_EXCEPTION, [ + 'exception' => $e, + 'exceptionHandler' => $handler, + 'exceptionHandlers' => $this->exceptionHandlers, + ]); + + $this->debug('Processing exception-handler "%s"', \get_class($handler)); + + if (($handler instanceof IExceptionHandler) === false) { + throw new HttpException('Exception handler must implement the IExceptionHandler interface.', 500); + } + + try { + $this->debug('Start rendering exception handler'); + $handler->handleError($this->request, $e); + $this->debug('Finished rendering exception-handler'); + + if (isset($this->loadedExceptionHandlers[$key]) === false && $this->request->hasPendingRewrite() === true) { + + $this->loadedExceptionHandlers[$key] = $handler; + + $this->debug('Exception handler contains rewrite, reloading routes'); + + $this->fireEvents(EventHandler::EVENT_REWRITE, [ + 'rewriteUrl' => $this->request->getRewriteUrl(), + 'rewriteRoute' => $this->request->getRewriteRoute(), + ]); + + if ($this->request->getRewriteRoute() !== null) { + $this->processedRoutes[] = $this->request->getRewriteRoute(); + } + + return $this->routeRequest(); + } + + } catch (\Exception $e) { + + } + + $this->debug('Finished processing'); + } + + $this->debug('Finished exception handling - exception not handled, throwing'); + throw $e; + } + + /** + * Find route by alias, class, callback or method. + * + * @param string $name + * @return ILoadableRoute|null + */ + public function findRoute(string $name): ?ILoadableRoute + { + $this->debug('Finding route by name "%s"', $name); + + $this->fireEvents(EventHandler::EVENT_FIND_ROUTE, [ + 'name' => $name, + ]); + + /* @var $route ILoadableRoute */ + foreach ($this->processedRoutes as $route) { + + /* Check if the name matches with a name on the route. Should match either router alias or controller alias. */ + if ($route->hasName($name) === true) { + $this->debug('Found route "%s" by name "%s"', $route->getUrl(), $name); + + return $route; + } + + /* Direct match to controller */ + if ($route instanceof IControllerRoute && strtoupper($route->getController()) === strtoupper($name)) { + $this->debug('Found route "%s" by controller "%s"', $route->getUrl(), $name); + + return $route; + } + + /* Using @ is most definitely a controller@method or alias@method */ + if (\is_string($name) === true && strpos($name, '@') !== false) { + [$controller, $method] = array_map('strtolower', explode('@', $name)); + + if ($controller === strtolower($route->getClass()) && $method === strtolower($route->getMethod())) { + $this->debug('Found route "%s" by controller "%s" and method "%s"', $route->getUrl(), $controller, $method); + + return $route; + } + } + + /* Check if callback matches (if it's not a function) */ + $callback = $route->getCallback(); + if (\is_string($name) === true && \is_string($callback) === true && strpos($name, '@') !== false && strpos($callback, '@') !== false && \is_callable($callback) === false) { + + /* Check if the entire callback is matching */ + if (strpos($callback, $name) === 0 || strtolower($callback) === strtolower($name)) { + $this->debug('Found route "%s" by callback "%s"', $route->getUrl(), $name); + + return $route; + } + + /* Check if the class part of the callback matches (class@method) */ + if (strtolower($name) === strtolower($route->getClass())) { + $this->debug('Found route "%s" by class "%s"', $route->getUrl(), $name); + + return $route; + } + } + } + + $this->debug('Route not found'); + + return null; + } + + /** + * Get url for a route by using either name/alias, class or method name. + * + * The name parameter supports the following values: + * - Route name + * - Controller/resource name (with or without method) + * - Controller class name + * + * When searching for controller/resource by name, you can use this syntax "route.name@method". + * You can also use the same syntax when searching for a specific controller-class "MyController@home". + * If no arguments is specified, it will return the url for the current loaded route. + * + * @param string|null $name + * @param string|array|null $parameters + * @param array|null $getParams + * @return Url + * @throws InvalidArgumentException + * @throws \Runtime\Exceptions\MalformedUrlException + */ + public function getUrl(?string $name = null, $parameters = null, ?array $getParams = null): Url + { + $this->debug('Finding url', \func_get_args()); + + $this->fireEvents(EventHandler::EVENT_GET_URL, [ + 'name' => $name, + 'parameters' => $parameters, + 'getParams' => $getParams, + ]); + + if ($getParams !== null && \is_array($getParams) === false) { + throw new InvalidArgumentException('Invalid type for getParams. Must be array or null'); + } + + if ($name === '' && $parameters === '') { + return new Url('/'); + } + + /* Only merge $_GET when all parameters are null */ + $getParams = ($name === null && $parameters === null && $getParams === null) ? $_GET : (array)$getParams; + + /* Return current route if no options has been specified */ + if ($name === null && $parameters === null) { + return $this->request + ->getUrlCopy() + ->setParams($getParams); + } + + $loadedRoute = $this->request->getLoadedRoute(); + + /* If nothing is defined and a route is loaded we use that */ + if ($name === null && $loadedRoute !== null) { + return $this->request + ->getUrlCopy() + ->setPath($loadedRoute->findUrl($loadedRoute->getMethod(), $parameters, $name)) + ->setParams($getParams); + } + + /* We try to find a match on the given name */ + $route = $this->findRoute($name); + + if ($route !== null) { + return $this->request + ->getUrlCopy() + ->setPath($route->findUrl($route->getMethod(), $parameters, $name)) + ->setParams($getParams); + } + + /* Using @ is most definitely a controller@method or alias@method */ + if (\is_string($name) === true && strpos($name, '@') !== false) { + [$controller, $method] = explode('@', $name); + + /* Loop through all the routes to see if we can find a match */ + + /* @var $route ILoadableRoute */ + foreach ($this->processedRoutes as $route) { + + /* Check if the route contains the name/alias */ + if ($route->hasName($controller) === true) { + return $this->request + ->getUrlCopy() + ->setPath($route->findUrl($method, $parameters, $name)) + ->setParams($getParams); + } + + /* Check if the route controller is equal to the name */ + if ($route instanceof IControllerRoute && strtolower($route->getController()) === strtolower($controller)) { + return $this->request + ->getUrlCopy() + ->setPath($route->findUrl($method, $parameters, $name)) + ->setParams($getParams); + } + + } + } + + /* No result so we assume that someone is using a hardcoded url and join everything together. */ + $url = trim(implode('/', array_merge((array)$name, (array)$parameters)), '/'); + $url = (($url === '') ? '/' : '/' . $url . '/'); + + return $this->request + ->getUrlCopy() + ->setPath($url) + ->setParams($getParams); + } + + /** + * Get BootManagers + * @return array + */ + public function getBootManagers(): array + { + return $this->bootManagers; + } + + /** + * Set BootManagers + * + * @param array $bootManagers + * @return static + */ + public function setBootManagers(array $bootManagers): self + { + $this->bootManagers = $bootManagers; + + return $this; + } + + /** + * Add BootManager + * + * @param IRouterBootManager $bootManager + * @return static + */ + public function addBootManager(IRouterBootManager $bootManager): self + { + $this->bootManagers[] = $bootManager; + + return $this; + } + + /** + * Get routes that has been processed. + * + * @return array + */ + public function getProcessedRoutes(): array + { + return $this->processedRoutes; + } + + /** + * @return array + */ + public function getRoutes(): array + { + return $this->routes; + } + + /** + * Set routes + * + * @param array $routes + * @return static + */ + public function setRoutes(array $routes): self + { + $this->routes = $routes; + + return $this; + } + + /** + * Get current request + * + * @return Request + */ + public function getRequest(): Request + { + return $this->request; + } + + /** + * Get csrf verifier class + * @return BaseCsrfVerifier + */ + public function getCsrfVerifier(): ?BaseCsrfVerifier + { + return $this->csrfVerifier; + } + + /** + * Set csrf verifier class + * + * @param BaseCsrfVerifier $csrfVerifier + */ + public function setCsrfVerifier(BaseCsrfVerifier $csrfVerifier): void + { + $this->csrfVerifier = $csrfVerifier; + } + + /** + * Set class loader + * + * @param IClassLoader $loader + */ + public function setClassLoader(IClassLoader $loader): void + { + $this->classLoader = $loader; + } + + /** + * Get class loader + * + * @return ClassLoader + */ + public function getClassLoader(): IClassLoader + { + return $this->classLoader; + } + + /** + * Register event handler + * + * @param IEventHandler $handler + */ + public function addEventHandler(IEventHandler $handler): void + { + $this->eventHandlers[] = $handler; + } + + /** + * Get registered event-handler. + * + * @return array + */ + public function getEventHandlers(): array + { + return $this->eventHandlers; + } + + /** + * Fire event in event-handler. + * + * @param string $name + * @param array $arguments + */ + protected function fireEvents($name, array $arguments = []): void + { + if (\count($this->eventHandlers) === 0) { + return; + } + + /* @var IEventHandler $eventHandler */ + foreach ($this->eventHandlers as $eventHandler) { + $eventHandler->fireEvents($this, $name, $arguments); + } + } + + /** + * Add new debug message + * @param string $message + * @param array $args + */ + public function debug(string $message, ...$args): void + { + if ($this->debugEnabled === false) { + return; + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + $this->debugList[] = [ + 'message' => vsprintf($message, $args), + 'time' => number_format(microtime(true) - $this->debugStartTime, 10), + 'trace' => end($trace), + ]; + } + + /** + * Enable or disables debugging + * + * @param bool $enabled + * @return static + */ + public function setDebugEnabled(bool $enabled): self + { + $this->debugEnabled = $enabled; + + return $this; + } + + /** + * Get the list containing all debug messages. + * + * @return array + */ + public function getDebugLog(): array + { + return $this->debugList; + } + +} \ No newline at end of file