diff --git a/README.md b/README.md index 9c46c50..140ca4f 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,68 @@ Flight::route('/', function(\flight\net\Route $route) { }, true); ``` +## Route Grouping + +There may be times when you want to group related routes together (such as `/api/v1`). +You can do this by using the `group` method: + +```php +Flight::group('/api/v1', function () { + Flight::route('/users', function () { + // Matches /api/v1/users + }); + + Flight::route('/posts', function () { + // Matches /api/v1/posts + }); +}); +``` + +You can even nest groups of groups: + +```php +Flight::group('/api', function () { + Flight::group('/v1', function () { + // Flight::get() gets variables, it doesn't set a route! See object context below + Flight::route('GET /users', function () { + // Matches GET /api/v1/users + }); + + Flight::post('/posts', function () { + // Matches POST /api/v1/posts + }); + + Flight::put('/posts/1', function () { + // Matches PUT /api/v1/posts + }); + }); + Flight::group('/v2', function () { + + // Flight::get() gets variables, it doesn't set a route! See object context below + Flight::route('GET /users', function () { + // Matches GET /api/v2/users + }); + }); +}); +``` + +### Grouping with Object Context + +You can still use route grouping with the `Engine` object in the following way: + +```php +$app = new \flight\Engine(); +$app->group('/api/v1', function (Router $router) { + $router->get('/users', function () { + // Matches GET /api/v1/users + }); + + $router->post('/posts', function () { + // Matches POST /api/v1/posts + }); +}); +``` + # Extending Flight is designed to be an extensible framework. The framework comes with a set diff --git a/flight/Engine.php b/flight/Engine.php index 4b1bdca..2628a11 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -33,7 +33,7 @@ use Throwable; * * Routing * @method void route(string $pattern, callable $callback, bool $pass_route = false) Routes a URL to a callback function. - * @method void get(string $pattern, callable $callback, bool $pass_route = false) Routes a GET URL to a callback function. + * @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. @@ -151,7 +151,7 @@ class Engine $methods = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonp', - 'post', 'put', 'patch', 'delete', + 'post', 'put', 'patch', 'delete', 'group', ]; foreach ($methods as $name) { $this->dispatcher->set($name, [$this, '_' . $name]); @@ -468,6 +468,17 @@ class Engine $this->router()->map($pattern, $callback, $pass_route); } + /** + * Routes a URL to a callback function. + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function that includes the Router class as first parameter + */ + public function _group(string $pattern, callable $callback): void + { + $this->router()->group($pattern, $callback); + } + /** * Routes a URL to a callback function. * diff --git a/flight/Flight.php b/flight/Flight.php index 69a8b65..b1a9b1f 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -24,6 +24,11 @@ use flight\template\View; * @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 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 Router router() Returns Router instance. * * @method static void map(string $name, callable $callback) Creates a custom framework method. @@ -123,11 +128,22 @@ class Flight if (!$initialized) { require_once __DIR__ . '/autoload.php'; - self::$engine = new Engine(); + self::setEngine(new Engine()); $initialized = true; } return self::$engine; } + + /** + * Set the engine instance + * + * @param Engine $engine Vroom vroom! + * @return void + */ + public static function setEngine(Engine $engine): void + { + self::$engine = $engine; + } } diff --git a/flight/net/Router.php b/flight/net/Router.php index f1f8842..078948c 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -32,6 +32,13 @@ class Router */ protected int $index = 0; + /** + * When groups are used, this is mapped against all the routes + * + * @var string + */ + protected string $group_prefix = ''; + /** * Gets mapped routes. * @@ -56,6 +63,7 @@ class Router * @param string $pattern URL pattern to match * @param callable $callback Callback function * @param bool $pass_route Pass the matching route object to the callback + * @return void */ public function map(string $pattern, callable $callback, bool $pass_route = false): void { @@ -68,9 +76,83 @@ class Router $methods = explode('|', $method); } - $this->routes[] = new Route($url, $callback, $methods, $pass_route); + $this->routes[] = new Route($this->group_prefix.$url, $callback, $methods, $pass_route); } + /** + * Creates a GET based route + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function + * @param bool $pass_route Pass the matching route object to the callback + * @return void + */ + public function get(string $pattern, callable $callback, bool $pass_route = false): void { + $this->map('GET ' . $pattern, $callback, $pass_route); + } + + /** + * Creates a POST based route + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function + * @param bool $pass_route Pass the matching route object to the callback + * @return void + */ + public function post(string $pattern, callable $callback, bool $pass_route = false): void { + $this->map('POST ' . $pattern, $callback, $pass_route); + } + + /** + * Creates a PUT based route + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function + * @param bool $pass_route Pass the matching route object to the callback + * @return void + */ + public function put(string $pattern, callable $callback, bool $pass_route = false): void { + $this->map('PUT ' . $pattern, $callback, $pass_route); + } + + /** + * Creates a PATCH based route + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function + * @param bool $pass_route Pass the matching route object to the callback + * @return void + */ + public function patch(string $pattern, callable $callback, bool $pass_route = false): void { + $this->map('PATCH ' . $pattern, $callback, $pass_route); + } + + /** + * Creates a DELETE based route + * + * @param string $pattern URL pattern to match + * @param callable $callback Callback function + * @param bool $pass_route Pass the matching route object to the callback + * @return void + */ + public function delete(string $pattern, callable $callback, bool $pass_route = false): void { + $this->map('DELETE ' . $pattern, $callback, $pass_route); + } + + /** + * Group together a set of routes + * + * @param string $group_prefix group URL prefix (such as /api/v1) + * @param callable $callback The necessary calling that holds the Router class + * @return void + */ + public function group(string $group_prefix, callable $callback): void { + $old_group_prefix = $this->group_prefix; + $this->group_prefix .= $group_prefix; + $callback($this); + $this->group_prefix = $old_group_prefix; + } + /** * Routes the current request. * diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 588ebb2..12b2772 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -1,5 +1,6 @@ url = '/test'; + + $this->expectOutputString('test'); + Flight::start(); + } + + public function testStaticRouteGroup() { + Flight::group('/group', function() { + Flight::route('/test', function() { + echo 'test'; + }); + }); + Flight::request()->url = '/group/test'; + + $this->expectOutputString('test'); + Flight::start(); + } + + public function testStaticRouteGet() { + + // can't actually get "get" because that gets a variable + Flight::route('GET /test', function() { + echo 'test get'; + }); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + Flight::request()->url = '/test'; + + $this->expectOutputString('test get'); + Flight::start(); + } + + public function testStaticRoutePost() { + + Flight::post('/test', function() { + echo 'test post'; + }); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + Flight::request()->url = '/test'; + + $this->expectOutputString('test post'); + Flight::start(); + } + + public function testStaticRoutePut() { + + Flight::put('/test', function() { + echo 'test put'; + }); + + $_SERVER['REQUEST_METHOD'] = 'PUT'; + Flight::request()->url = '/test'; + + $this->expectOutputString('test put'); + Flight::start(); + } + + public function testStaticRoutePatch() { + + Flight::patch('/test', function() { + echo 'test patch'; + }); + + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + Flight::request()->url = '/test'; + + $this->expectOutputString('test patch'); + Flight::start(); + } + + public function testStaticRouteDelete() { + + Flight::delete('/test', function() { + echo 'test delete'; + }); + + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + Flight::request()->url = '/test'; + + $this->expectOutputString('test delete'); + Flight::start(); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 9adc7fc..5cc222d 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -106,7 +106,6 @@ class RouterTest extends PHPUnit\Framework\TestCase } // Simple path with trailing slash - // Simple path public function testPathRouteTrailingSlash() { $this->router->map('/path/', [$this, 'ok']); @@ -115,6 +114,15 @@ class RouterTest extends PHPUnit\Framework\TestCase $this->check('OK'); } + public function testGetRouteShortcut() + { + $this->router->get('/path', [$this, 'ok']); + $this->request->url = '/path'; + $this->request->method = 'GET'; + + $this->check('OK'); + } + // POST route public function testPostRoute() { @@ -125,6 +133,15 @@ class RouterTest extends PHPUnit\Framework\TestCase $this->check('OK'); } + public function testPostRouteShortcut() + { + $this->router->post('/path', [$this, 'ok']); + $this->request->url = '/path'; + $this->request->method = 'POST'; + + $this->check('OK'); + } + // Either GET or POST route public function testGetPostRoute() { @@ -135,6 +152,30 @@ class RouterTest extends PHPUnit\Framework\TestCase $this->check('OK'); } + public function testPutRouteShortcut() { + $this->router->put('/path', [$this, 'ok']); + $this->request->url = '/path'; + $this->request->method = 'PUT'; + + $this->check('OK'); + } + + public function testPatchRouteShortcut() { + $this->router->patch('/path', [$this, 'ok']); + $this->request->url = '/path'; + $this->request->method = 'PATCH'; + + $this->check('OK'); + } + + public function testDeleteRouteShortcut() { + $this->router->delete('/path', [$this, 'ok']); + $this->request->url = '/path'; + $this->request->method = 'DELETE'; + + $this->check('OK'); + } + // Test regular expression matching public function testRegEx() { @@ -208,6 +249,12 @@ class RouterTest extends PHPUnit\Framework\TestCase $this->check('OK'); } + public function testWildcardDuplicate() { + $this->router->map('/account/*' , [$this, 'ok']); + $this->request->url = '/account/account/account'; + $this->check('OK'); + } + // Check if route object was passed public function testRouteObjectPassing() { @@ -352,4 +399,66 @@ class RouterTest extends PHPUnit\Framework\TestCase $router->reset(); $this->assertEquals(0, $router->getIndex()); } + + // Passing URL parameters + public function testGroupRoutes() + { + $this->router->group('/user', function(Router $router) { + $router->map('/@id', function ($id) { + echo $id; + }); + $router->map('/@id/@name', function ($id, $name) { + echo $id . $name; + }); + }); + $this->request->url = '/user/123'; + $this->check('123'); + } + + public function testGroupRoutesMultiParams() + { + $this->router->group('/user', function(Router $router) { + $router->map('/@id', function ($id) { + echo $id; + }); + $router->map('/@id/@name', function ($id, $name) { + echo $id . $name; + }); + }); + $this->request->url = '/user/123/abc'; + $this->check('123abc'); + } + + public function testGroupNestedRoutes() + { + $this->router->group('/client', function(Router $router) { + $router->group('/user', function(Router $router) { + $router->map('/@id', function ($id) { + echo $id; + }); + $router->map('/@id/@name', function ($id, $name) { + echo $id . $name; + }); + }); + }); + $this->request->url = '/client/user/123/abc'; + $this->check('123abc'); + } + + public function testGroupNestedRoutesWithCustomMethods() + { + $this->router->group('/client', function(Router $router) { + $router->group('/user', function(Router $router) { + $router->get('/@id', function ($id) { + echo $id; + }); + $router->post('/@id/@name', function ($id, $name) { + echo $id . $name; + }); + }); + }); + $this->request->url = '/client/user/123/abc'; + $this->request->method = 'POST'; + $this->check('123abc'); + } }