diff --git a/flight/Engine.php b/flight/Engine.php index 2628a11d..44affc1f 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -32,13 +32,14 @@ * @method void halt(int $code = 200, string $message = '') Stops processing and returns a given response. * * Routing - * @method void route(string $pattern, callable $callback, bool $pass_route = false) Routes a URL to a callback function. + * @method void route(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a URL to a callback function with all applicable methods * @method void group(string $pattern, callable $callback) Groups a set of routes together under a common prefix. - * @method void post(string $pattern, callable $callback, bool $pass_route = false) Routes a POST URL to a callback function. - * @method void put(string $pattern, callable $callback, bool $pass_route = false) Routes a PUT URL to a callback function. - * @method void patch(string $pattern, callable $callback, bool $pass_route = false) Routes a PATCH URL to a callback function. - * @method void delete(string $pattern, callable $callback, bool $pass_route = false) Routes a DELETE URL to a callback function. + * @method void post(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a POST URL to a callback function. + * @method void put(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a PUT URL to a callback function. + * @method void patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a PATCH URL to a callback function. + * @method void delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a DELETE URL to a callback function. * @method Router router() Gets router + * @method string getUrl(string $alias) Gets a url from an alias * * Views * @method void render(string $file, array $data = null, string $key = null) Renders template @@ -151,7 +152,7 @@ public function init(): void $methods = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', ]; foreach ($methods as $name) { $this->dispatcher->set($name, [$this, '_' . $name]); @@ -462,10 +463,11 @@ public function _stop(?int $code = null): void * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias the alias for the route */ - public function _route(string $pattern, callable $callback, bool $pass_route = false): void + public function _route(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): void { - $this->router()->map($pattern, $callback, $pass_route); + $this->router()->map($pattern, $callback, $pass_route, $alias); } /** @@ -701,4 +703,16 @@ public function _lastModified(int $time): void $this->halt(304); } } + + /** + * Gets a url from an alias that's supplied. + * + * @param string $alias the route alias + * @param array the params for the route if applicable + * @return string + */ + public function _getUrl(string $alias, array $params = []): string + { + return $this->router()->getUrlByAlias($alias, $params); + } } diff --git a/flight/Flight.php b/flight/Flight.php index b1a9b1f4..fd3772c6 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -23,13 +23,14 @@ * @method static void stop() Stops the framework and sends a response. * @method static void halt(int $code = 200, string $message = '') Stop the framework with an optional status code and message. * - * @method static void route(string $pattern, callable $callback, bool $pass_route = false) Maps a URL pattern to a callback. + * @method static void route(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Maps a URL pattern to a callback with all applicable methods. * @method static void group(string $pattern, callable $callback) Groups a set of routes together under a common prefix. - * @method void post(string $pattern, callable $callback, bool $pass_route = false) Routes a POST URL to a callback function. - * @method void put(string $pattern, callable $callback, bool $pass_route = false) Routes a PUT URL to a callback function. - * @method void patch(string $pattern, callable $callback, bool $pass_route = false) Routes a PATCH URL to a callback function. - * @method void delete(string $pattern, callable $callback, bool $pass_route = false) Routes a DELETE URL to a callback function. + * @method static void post(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a POST URL to a callback function. + * @method static void put(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a PUT URL to a callback function. + * @method static void patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a PATCH URL to a callback function. + * @method static void delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') Routes a DELETE URL to a callback function. * @method static Router router() Returns Router instance. + * @method static string getUrl(string $alias) Gets a url from an alias * * @method static void map(string $name, callable $callback) Creates a custom framework method. * diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index ac87aff0..7189cbdf 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -13,7 +13,7 @@ class PdoWrapper extends PDO { * @param string $dsn - Ex: 'mysql:host=localhost;port=3306;dbname=testdb;charset=utf8mb4' * @param string $username - Ex: 'root' * @param string $password - Ex: 'password' - * @param array $options - PDO options you can pass in + * @param array $options - PDO options you can pass in */ public function __construct(string $dsn, ?string $username = null, ?string $password = null, array $options = []) { parent::__construct($dsn, $username, $password, $options); @@ -31,7 +31,7 @@ public function __construct(string $dsn, ?string $username = null, ?string $pass * $db->runQuery("UPDATE table SET name = ? WHERE id = ?", [ $name, $id ]); * * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" - * @param array $params - Ex: [ $something ] + * @param array $params - Ex: [ $something ] * @return PDOStatement */ public function runQuery(string $sql, array $params = []): PDOStatement { @@ -49,12 +49,12 @@ public function runQuery(string $sql, array $params = []): PDOStatement { * Ex: $id = $db->fetchField("SELECT id FROM table WHERE something = ?", [ $something ]); * * @param string $sql - Ex: "SELECT id FROM table WHERE something = ?" - * @param array $params - Ex: [ $something ] + * @param array $params - Ex: [ $something ] * @return mixed */ public function fetchField(string $sql, array $params = []) { $data = $this->fetchRow($sql, $params); - return is_array($data) ? reset($data) : null; + return reset($data); } /** @@ -63,13 +63,13 @@ public function fetchField(string $sql, array $params = []) { * Ex: $row = $db->fetchRow("SELECT * FROM table WHERE something = ?", [ $something ]); * * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" - * @param array $params - Ex: [ $something ] - * @return array + * @param array $params - Ex: [ $something ] + * @return array */ public function fetchRow(string $sql, array $params = []): array { $sql .= stripos($sql, 'LIMIT') === false ? ' LIMIT 1' : ''; $result = $this->fetchAll($sql, $params); - return is_array($result) && count($result) ? $result[0] : []; + return count($result) > 0 ? $result[0] : []; } /** @@ -81,8 +81,8 @@ public function fetchRow(string $sql, array $params = []): array { * } * * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" - * @param array $params - Ex: [ $something ] - * @return array + * @param array $params - Ex: [ $something ] + * @return array> */ public function fetchAll(string $sql, array $params = []): array { $processed_sql_data = $this->processInStatementSql($sql, $params); @@ -101,8 +101,8 @@ public function fetchAll(string $sql, array $params = []): array { * Converts this to "SELECT * FROM table WHERE id = ? AND something IN(?,?,?)" * * @param string $sql the sql statement - * @param array $params the params for the sql statement - * @return array{sql:string,params:array} + * @param array $params the params for the sql statement + * @return array> */ protected function processInStatementSql(string $sql, array $params = []): array { diff --git a/flight/net/Request.php b/flight/net/Request.php index 8a40a504..88be447c 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -145,7 +145,6 @@ final class Request * Constructor. * * @param array $config Request configuration - * @param string */ public function __construct($config = array()) { @@ -210,7 +209,7 @@ public function init(array $properties = []) // Check for JSON input if (0 === strpos($this->type, 'application/json')) { $body = $this->getBody(); - if ('' !== $body && null !== $body) { + if ('' !== $body) { $data = json_decode($body, true); if (is_array($data)) { $this->data->setData($data); @@ -226,7 +225,7 @@ public function init(array $properties = []) * * @return string Raw HTTP request body */ - public function getBody(): ?string + public function getBody(): string { $body = $this->body; diff --git a/flight/net/Route.php b/flight/net/Route.php index fbfc20b2..63b1bf05 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -52,6 +52,11 @@ final class Route */ public bool $pass = false; + /** + * @var string The alias is a way to identify the route using a simple name ex: 'login' instead of /admin/login + */ + public string $alias = ''; + /** * Constructor. * @@ -60,12 +65,13 @@ final class Route * @param array $methods HTTP methods * @param bool $pass Pass self in callback parameters */ - public function __construct(string $pattern, $callback, array $methods, bool $pass) + public function __construct(string $pattern, $callback, array $methods, bool $pass, string $alias = '') { $this->pattern = $pattern; $this->callback = $callback; $this->methods = $methods; $this->pass = $pass; + $this->alias = $alias; } /** @@ -129,7 +135,7 @@ static function ($matches) use (&$ids) { } // Attempt to match route and named parameters - if (preg_match('#^' . $regex . '(?:\?.*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { + if (preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { foreach ($ids as $k => $v) { $this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null; } @@ -153,4 +159,35 @@ public function matchMethod(string $method): bool { return \count(array_intersect([$method, '*'], $this->methods)) > 0; } + + /** + * Checks if an alias matches the route alias. + * + * @param string $alias [description] + * @return boolean + */ + public function matchAlias(string $alias): bool + { + return $this->alias === $alias; + } + + /** + * Hydrates the route url with the given parameters + * + * @param array $params the parameters to pass to the route + * @return string + */ + public function hydrateUrl(array $params = []): string { + $url = preg_replace_callback("/(?:@([a-zA-Z0-9]+)(?:\:([^\/]+))?\)*)/i", function($match) use ($params) { + if(isset($match[1]) && isset($params[$match[1]])) { + return $params[$match[1]]; + } + }, $this->pattern); + + // catches potential optional parameter + $url = str_replace('(/', '/', $url); + // trim any trailing slashes + $url = rtrim($url, '/'); + return $url; + } } diff --git a/flight/net/Router.php b/flight/net/Router.php index 078948cb..f8256165 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -10,6 +10,9 @@ namespace flight\net; +use Exception; +use flight\net\Route; + /** * The Router class is responsible for routing an HTTP request to * an assigned callback function. The Router tries to match the @@ -42,7 +45,7 @@ class Router /** * Gets mapped routes. * - * @return array Array of routes + * @return array Array of routes */ public function getRoutes(): array { @@ -63,9 +66,10 @@ public function clear(): void * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $route_alias Alias for the route * @return void */ - public function map(string $pattern, callable $callback, bool $pass_route = false): void + public function map(string $pattern, callable $callback, bool $pass_route = false, string $route_alias = ''): void { $url = trim($pattern); $methods = ['*']; @@ -76,7 +80,7 @@ public function map(string $pattern, callable $callback, bool $pass_route = fals $methods = explode('|', $method); } - $this->routes[] = new Route($this->group_prefix.$url, $callback, $methods, $pass_route); + $this->routes[] = new Route($this->group_prefix.$url, $callback, $methods, $pass_route, $route_alias); } /** @@ -85,10 +89,11 @@ public function map(string $pattern, callable $callback, bool $pass_route = fals * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route * @return void */ - public function get(string $pattern, callable $callback, bool $pass_route = false): void { - $this->map('GET ' . $pattern, $callback, $pass_route); + public function get(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): void { + $this->map('GET ' . $pattern, $callback, $pass_route, $alias); } /** @@ -97,10 +102,11 @@ public function get(string $pattern, callable $callback, bool $pass_route = fals * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route * @return void */ - public function post(string $pattern, callable $callback, bool $pass_route = false): void { - $this->map('POST ' . $pattern, $callback, $pass_route); + public function post(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): void { + $this->map('POST ' . $pattern, $callback, $pass_route, $alias); } /** @@ -109,10 +115,11 @@ public function post(string $pattern, callable $callback, bool $pass_route = fal * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route * @return void */ - public function put(string $pattern, callable $callback, bool $pass_route = false): void { - $this->map('PUT ' . $pattern, $callback, $pass_route); + public function put(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): void { + $this->map('PUT ' . $pattern, $callback, $pass_route, $alias); } /** @@ -121,10 +128,11 @@ public function put(string $pattern, callable $callback, bool $pass_route = fals * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route * @return void */ - public function patch(string $pattern, callable $callback, bool $pass_route = false): void { - $this->map('PATCH ' . $pattern, $callback, $pass_route); + public function patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): void { + $this->map('PATCH ' . $pattern, $callback, $pass_route, $alias); } /** @@ -133,10 +141,11 @@ public function patch(string $pattern, callable $callback, bool $pass_route = fa * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @param string $alias Alias for the route * @return void */ - public function delete(string $pattern, callable $callback, bool $pass_route = false): void { - $this->map('DELETE ' . $pattern, $callback, $pass_route); + public function delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = ''): void { + $this->map('DELETE ' . $pattern, $callback, $pass_route, $alias); } /** @@ -173,6 +182,42 @@ public function route(Request $request) return false; } + /** + * Gets the URL for a given route alias + * + * @param string $alias the alias to match + * @param array $params the parameters to pass to the route + * @return string + */ + public function getUrlByAlias(string $alias, array $params = []): string { + while ($route = $this->current()) { + if ($route->matchAlias($alias)) { + return $route->hydrateUrl($params); + } + $this->next(); + } + + throw new Exception('No route found with alias: ' . $alias); + } + + /** + * Rewinds the current route index. + */ + public function rewind(): void + { + $this->index = 0; + } + + /** + * Checks if more routes can be iterated. + * + * @return bool More routes + */ + public function valid(): bool + { + return isset($this->routes[$this->index]); + } + /** * Gets the current route. * diff --git a/tests/EngineTest.php b/tests/EngineTest.php index cf2b13af..e1b9aaf1 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -265,4 +265,11 @@ public function _halt(int $code = 200, string $message = ''): void $this->assertEquals('Fri, 13 Feb 2009 23:31:30 GMT', $engine->response()->headers()['Last-Modified']); $this->assertEquals(304, $engine->response()->status()); } + + public function testGetUrl() { + $engine = new Engine; + $engine->route('/path1/@param:[0-9]{3}', function() { echo 'I win'; }, false, 'path1'); + $url = $engine->getUrl('path1', [ 'param' => 123 ]); + $this->assertEquals('/path1/123', $url); + } } diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 12b2772a..7a3df8b0 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -187,4 +187,37 @@ public function testStaticRouteDelete() { $this->expectOutputString('test delete'); Flight::start(); } + + public function testGetUrl() { + Flight::route('/path1/@param:[a-zA-Z0-9]{2,3}', function() { echo 'I win'; }, false, 'path1'); + $url = Flight::getUrl('path1', [ 'param' => 123 ]); + $this->assertEquals('/path1/123', $url); + } + + public function testRouteGetUrlWithGroupSimpleParams() { + Flight::group('/path1/@id', function() { + Flight::route('/@name', function() { echo 'whatever'; }, false, 'path1'); + }); + $url = Flight::getUrl('path1', ['id' => 123, 'name' => 'abc']); + + $this->assertEquals('/path1/123/abc', $url); + } + + public function testRouteGetUrlNestedGroups() { + Flight::group('/user', function () { + Flight::group('/all_users', function () { + Flight::group('/check_user', function () { + Flight::group('/check_one', function () { + Flight::route("/normalpath", function () { + echo "normalpath"; + },false,"normalpathalias"); + }); + }); + }); + }); + + $url = Flight::getUrl('normalpathalias'); + + $this->assertEquals('/user/all_users/check_user/check_one/normalpath', $url); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 5cc222df..6125f53d 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -255,6 +255,13 @@ public function testWildcardDuplicate() { $this->check('OK'); } + public function testRouteWithLongQueryParamWithMultilineEncoded() + { + $this->router->map('GET /api/intune/hey', [$this, 'ok']); + $this->request->url = '/api/intune/hey?error=access_denied&error_description=AADSTS65004%3a+User+declined+to+consent+to+access+the+app.%0d%0aTrace+ID%3a+747c0cc1-ccbd-4e53-8e2f-48812eb24100%0d%0aCorrelation+ID%3a+362e3cb3-20ef-400b-904e-9983bd989184%0d%0aTimestamp%3a+2022-09-08+09%3a58%3a12Z&error_uri=https%3a%2f%2flogin.microsoftonline.com%2ferror%3fcode%3d65004&admin_consent=True&state=x2EUE0fcSj#'; + $this->check('OK'); + } + // Check if route object was passed public function testRouteObjectPassing() { @@ -461,4 +468,117 @@ public function testGroupNestedRoutesWithCustomMethods() $this->request->method = 'POST'; $this->check('123abc'); } + + public function testRewindAndValid() { + $this->router->map('/path1', [$this, 'ok']); + $this->router->map('/path2', [$this, 'ok']); + $this->router->map('/path3', [$this, 'ok']); + + $this->router->next(); + $this->router->next(); + $result = $this->router->valid(); + $this->assertTrue($result); + $this->router->next(); + $result = $this->router->valid(); + $this->assertFalse($result); + + $this->router->rewind(); + $result = $this->router->valid(); + $this->assertTrue($result); + + } + + public function testGetUrlByAliasNoMatches() { + $this->router->map('/path1', [$this, 'ok'], false, 'path1'); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('No route found with alias: path2'); + $this->router->getUrlByAlias('path2'); + } + + public function testGetUrlByAliasNoParams() { + $this->router->map('/path1', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1'); + $this->assertEquals('/path1', $url); + } + + public function testGetUrlByAliasSimpleParams() { + $this->router->map('/path1/@id', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id' => 123]); + $this->assertEquals('/path1/123', $url); + } + + public function testGetUrlByAliasSimpleParamsWithNumber() { + $this->router->map('/path1/@id1', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id1' => 123]); + $this->assertEquals('/path1/123', $url); + } + + public function testGetUrlByAliasSimpleOptionalParamsWithParam() { + $this->router->map('/path1(/@id)', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id' => 123]); + $this->assertEquals('/path1/123', $url); + } + + public function testGetUrlByAliasSimpleOptionalParamsWithNumberWithParam() { + $this->router->map('/path1(/@id1)', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id1' => 123]); + $this->assertEquals('/path1/123', $url); + } + + public function testGetUrlByAliasSimpleOptionalParamsNoParam() { + $this->router->map('/path1(/@id)', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1'); + $this->assertEquals('/path1', $url); + } + + public function testGetUrlByAliasSimpleOptionalParamsWithNumberNoParam() { + $this->router->map('/path1(/@id1)', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1'); + $this->assertEquals('/path1', $url); + } + + public function testGetUrlByAliasMultipleParams() { + $this->router->map('/path1/@id/@name', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id' => 123, 'name' => 'abc']); + $this->assertEquals('/path1/123/abc', $url); + } + + public function testGetUrlByAliasMultipleComplexParams() { + $this->router->map('/path1/@id:[0-9]+/@name:[a-zA-Z0-9]{5}', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id' => '123', 'name' => 'abc']); + $this->assertEquals('/path1/123/abc', $url); + } + + public function testGetUrlByAliasMultipleComplexParamsWithNumbers() { + $this->router->map('/path1/@5id:[0-9]+/@n1ame:[a-zA-Z0-9]{5}', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['5id' => '123', 'n1ame' => 'abc']); + $this->assertEquals('/path1/123/abc', $url); + } + + public function testGetUrlByAliasMultipleComplexOptionalParamsMissingOne() { + $this->router->map('/path1(/@id:[0-9]+(/@name(/@crazy:[a-z]{5})))', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id' => '123', 'name' => 'abc']); + $this->assertEquals('/path1/123/abc', $url); + } + + public function testGetUrlByAliasMultipleComplexOptionalParamsAllParams() { + $this->router->map('/path1(/@id:[0-9]+(/@name(/@crazy:[a-z]{5})))', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1', ['id' => '123', 'name' => 'abc', 'crazy' => 'xyz']); + $this->assertEquals('/path1/123/abc/xyz', $url); + } + + public function testGetUrlByAliasMultipleComplexOptionalParamsNoParams() { + $this->router->map('/path1(/@id:[0-9]+(/@name(/@crazy:[a-z]{5})))', [$this, 'ok'], false, 'path1'); + $url = $this->router->getUrlByAlias('path1'); + $this->assertEquals('/path1', $url); + } + + public function testGetUrlByAliasWithGroupSimpleParams() { + $this->router->group('/path1/@id', function($router) { + $router->get('/@name', [$this, 'ok'], false, 'path1'); + }); + $url = $this->router->getUrlByAlias('path1', ['id' => 123, 'name' => 'abc']); + + $this->assertEquals('/path1/123/abc', $url); + } }