diff --git a/Dockerfile.fpm b/Dockerfile.fpm index b13a6496..dc243eb7 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -41,7 +41,7 @@ COPY --from=composer:2.7 /usr/bin/composer /usr/local/bin/composer COPY ./src /usr/share/nginx/html/src COPY ./tests /usr/share/nginx/html/tests COPY ./phpunit.xml /usr/share/nginx/html/phpunit.xml -COPY ./composer.json ./composer.lock ./phpstan.neon ./pint.json /usr/share/nginx/html/ +COPY ./composer.json ./composer.lock ./phpstan.neon /usr/share/nginx/html/ COPY --from=step0 /usr/local/src/vendor /usr/share/nginx/html/vendor # Supervisord Conf diff --git a/Dockerfile.swoole b/Dockerfile.swoole index 6ddb4309..4355b8ad 100644 --- a/Dockerfile.swoole +++ b/Dockerfile.swoole @@ -24,7 +24,7 @@ COPY --from=composer:2.7 /usr/bin/composer /usr/local/bin/composer COPY ./src /usr/src/code/src COPY ./tests /usr/src/code/tests COPY ./phpunit.xml /usr/src/code/phpunit.xml -COPY ./composer.json ./composer.lock ./phpstan.neon ./pint.json /usr/src/code/ +COPY ./composer.json ./composer.lock ./phpstan.neon /usr/src/code/ COPY --from=step0 /usr/local/src/vendor /usr/src/code/vendor EXPOSE 80 diff --git a/README.md b/README.md index 25a00afe..8f8b3ded 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,36 @@ curl http://localhost:8000/hello-world?name=Appwrite It's always recommended to use params instead of getting params or body directly from the request resource. If you do that intentionally, always make sure to run validation right after fetching such a raw input. +### Multiple Methods + +A route can be registered under additional paths and multiple HTTP methods. All matching paths and methods dispatch to the same route, so the action, params, and hooks are defined only once. + +Use `alias()` to serve the same route under another path, for example to keep a legacy URL working: + +```php +Http::get('/users/:id') + ->alias('/members/:id') + ->param('id', '', new Text(256), 'User ID') + ->inject('response') + ->action(function(string $id, Response $response) { + $response->json(['id' => $id]); + }); +``` + +Use `routes()` to serve the same route under multiple HTTP methods. For example, the OpenID Connect UserInfo endpoint must support both GET and POST: + +```php +Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/oauth/userinfo') + ->inject('request') + ->inject('response') + ->action(function(Request $request, Response $response) { + // $request->getMethod() tells how the request arrived (GET or POST) + $response->json(['sub' => 'user-id']); + }); +``` + +Path aliases and multiple methods combine: a route with both responds on every method under every path. Use `getMethods()` to inspect the methods a route was registered with, and use the request resource to tell how a request arrived. + ### Hooks There are three types of hooks: diff --git a/src/Http/Http.php b/src/Http/Http.php index d13e0491..9c35a9b4 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -179,7 +179,7 @@ public function setCompressionSupported(mixed $compressionSupported): void */ public static function get(string $url): Route { - return self::addRoute(self::REQUEST_METHOD_GET, $url); + return self::routes(self::REQUEST_METHOD_GET, $url); } /** @@ -189,7 +189,7 @@ public static function get(string $url): Route */ public static function post(string $url): Route { - return self::addRoute(self::REQUEST_METHOD_POST, $url); + return self::routes(self::REQUEST_METHOD_POST, $url); } /** @@ -199,7 +199,7 @@ public static function post(string $url): Route */ public static function put(string $url): Route { - return self::addRoute(self::REQUEST_METHOD_PUT, $url); + return self::routes(self::REQUEST_METHOD_PUT, $url); } /** @@ -209,7 +209,7 @@ public static function put(string $url): Route */ public static function patch(string $url): Route { - return self::addRoute(self::REQUEST_METHOD_PATCH, $url); + return self::routes(self::REQUEST_METHOD_PATCH, $url); } /** @@ -219,7 +219,37 @@ public static function patch(string $url): Route */ public static function delete(string $url): Route { - return self::addRoute(self::REQUEST_METHOD_DELETE, $url); + return self::routes(self::REQUEST_METHOD_DELETE, $url); + } + + /** + * ROUTES + * + * Add one route under one or more request methods + * + * @param string|array $methods + */ + public static function routes(string|array $methods, string $url): Route + { + $methods = \is_array($methods) ? $methods : [$methods]; + $methods = array_values(array_unique($methods)); + + if (empty($methods)) { + throw new \Exception('At least one HTTP method is required.'); + } + + $routes = Router::getRoutes(); + + foreach ($methods as $method) { + if (!\array_key_exists($method, $routes)) { + throw new \Exception("Method ({$method}) not supported."); + } + } + + $route = new Route($methods, $url); + Router::addRoute($route); + + return $route; } /** diff --git a/src/Http/Route.php b/src/Http/Route.php index ab1d769c..64b00365 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -7,9 +7,11 @@ class Route extends Hook { /** - * HTTP Method + * HTTP Methods + * + * @var array */ - protected string $method = ''; + protected array $methods = []; /** * Whether to use hook @@ -28,6 +30,13 @@ class Route extends Hook */ protected array $pathParams = []; + /** + * Alias paths this route is also registered under. + * + * @var array + */ + protected array $aliasPaths = []; + /** * Internal counter. */ @@ -38,11 +47,14 @@ class Route extends Hook */ protected int $order; - public function __construct(string $method, string $path) + /** + * @param string|array $methods + */ + public function __construct(string|array $methods, string $path) { parent::__construct(); $this->path($path); - $this->method = $method; + $this->methods = \is_array($methods) ? array_values(array_unique($methods)) : [$methods]; $this->order = ++self::$counter; } @@ -71,6 +83,10 @@ public function alias(string $path): self { Router::addRouteAlias($path, $this); + if (!\in_array($path, $this->aliasPaths, true)) { + $this->aliasPaths[] = $path; + } + return $this; } @@ -85,11 +101,13 @@ public function hook(bool $hook = true): self } /** - * Get HTTP Method + * Get primary HTTP method. + * + * @deprecated Use getMethods() instead. */ public function getMethod(): string { - return $this->method; + return $this->methods[0] ?? ''; } /** @@ -108,6 +126,16 @@ public function getHook(): bool return $this->hook; } + /** + * Get HTTP methods this route is registered under. + * + * @return array + */ + public function getMethods(): array + { + return $this->methods; + } + /** * Set path param. */ diff --git a/src/Http/Router.php b/src/Http/Router.php index 81ef21dc..09744ba6 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -74,20 +74,41 @@ public static function setAllowOverride(bool $value): void public static function addRoute(Route $route): void { [$path, $params] = self::preparePath($route->getPath()); + $methods = $route->getMethods(); + $method = $methods[0] ?? ''; + $additionalMethods = \array_slice($methods, 1); - if (!\array_key_exists($route->getMethod(), self::$routes)) { - throw new Exception("Method ({$route->getMethod()}) not supported."); + if (!\array_key_exists($method, self::$routes)) { + throw new Exception("Method ({$method}) not supported."); + } + + if (\array_key_exists($path, self::$routes[$method]) && !self::$allowOverride) { + throw new Exception("Route for ({$method}:{$path}) already registered."); } - if (\array_key_exists($path, self::$routes[$route->getMethod()]) && !self::$allowOverride) { - throw new Exception("Route for ({$route->getMethod()}:{$path}) already registered."); + foreach ($additionalMethods as $additionalMethod) { + if (!\array_key_exists($additionalMethod, self::$routes)) { + throw new Exception("Method ({$additionalMethod}) not supported."); + } + + if ($route->getPath() === '') { + throw new Exception('Additional route methods are not supported for the wildcard route.'); + } + + if (\array_key_exists($path, self::$routes[$additionalMethod]) && !self::$allowOverride) { + throw new Exception("Route for ({$additionalMethod}:{$path}) already registered."); + } } foreach ($params as $key => $index) { $route->setPathParam($key, $index, $path); } - self::$routes[$route->getMethod()][$path] = $route; + self::$routes[$method][$path] = $route; + + foreach ($additionalMethods as $additionalMethod) { + self::$routes[$additionalMethod][$path] = $route; + } } /** @@ -97,17 +118,26 @@ public static function addRoute(Route $route): void */ public static function addRouteAlias(string $path, Route $route): void { + $methods = $route->getMethods(); [$alias, $params] = self::preparePath($path); - if (\array_key_exists($alias, self::$routes[$route->getMethod()]) && !self::$allowOverride) { - throw new Exception("Route for ({$route->getMethod()}:{$alias}) already registered."); + foreach ($methods as $method) { + if (!\array_key_exists($method, self::$routes)) { + throw new Exception("Method ({$method}) not supported."); + } + + if (\array_key_exists($alias, self::$routes[$method]) && !self::$allowOverride) { + throw new Exception("Route for ({$method}:{$alias}) already registered."); + } } foreach ($params as $key => $index) { $route->setPathParam($key, $index, $alias); } - self::$routes[$route->getMethod()][$alias] = $route; + foreach ($methods as $method) { + self::$routes[$method][$alias] = $route; + } } /** @@ -232,6 +262,7 @@ public static function reset(): void { self::$params = []; self::$wildcard = null; + self::$allowOverride = false; self::$routes = [ Http::REQUEST_METHOD_GET => [], Http::REQUEST_METHOD_POST => [], diff --git a/tests/HttpTest.php b/tests/HttpTest.php index e1fa32e0..079f430b 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -250,6 +250,29 @@ public function testCanExecuteRoute(): void $this->assertSame('init-' . $resource . '-(init-homepage)-param-x*param-y-(shutdown-homepage)-shutdown', $result); } + public function testCanExecuteRouteWithMultipleMethods(): void + { + Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/v1/oauth/userinfo') + ->inject('request') + ->action(function ($request) { + echo 'userinfo:' . $request->getMethod(); + }); + + $_SERVER['REQUEST_URI'] = '/v1/oauth/userinfo'; + + $_SERVER['REQUEST_METHOD'] = 'GET'; + ob_start(); + $this->http->run(new Request(), new Response()); + $result = ob_get_clean(); + $this->assertSame('userinfo:GET', $result); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + ob_start(); + $this->http->run(new Request(), new Response()); + $result = ob_get_clean(); + $this->assertSame('userinfo:POST', $result); + } + public function testCanAddAndExecuteHooks(): void { Http::setAllowOverride(true); diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 8c766ed4..81d91590 100755 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -19,6 +19,7 @@ public function setUp(): void public function testCanGetMethod(): void { $this->assertSame('GET', $this->route->getMethod()); + $this->assertSame(['GET'], $this->route->getMethods()); } public function testCanGetAndSetPath(): void diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 5eaa0c72..9d5b67b3 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -8,6 +8,11 @@ final class RouterTest extends TestCase { + public function setUp(): void + { + Router::setAllowOverride(false); + } + public function tearDown(): void { Router::reset(); @@ -137,6 +142,132 @@ public function testCanMatchMix(): void $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')?->route); } + public function testCanMatchRouteWithMultipleMethods(): void + { + $route = Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/userinfo'); + + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/userinfo')?->route); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_POST, '/userinfo')?->route); + $this->assertNull(Router::match(Http::REQUEST_METHOD_PUT, '/userinfo')); + + $this->assertSame([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], $route->getMethods()); + } + + public function testCanMatchRouteWithStringMethod(): void + { + $route = Http::routes(Http::REQUEST_METHOD_GET, '/userinfo'); + + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/userinfo')?->route); + $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/userinfo')); + } + + public function testCanMatchRouteWithMultipleMethodsAndPlaceholder(): void + { + $route = Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/users/:id'); + + $match = Router::match(Http::REQUEST_METHOD_POST, '/users/abc-123'); + + $this->assertEquals($route, $match?->route); + $this->assertSame(['id' => 'abc-123'], $match?->params); + } + + public function testRoutesCrossPathAliases(): void + { + $route = Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/a') + ->alias('/a-old'); + + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/a')?->route); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_POST, '/a')?->route); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/a-old')?->route); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_POST, '/a-old')?->route); + + $routePOST = Http::routes(Http::REQUEST_METHOD_POST, '/b')->alias('/b-old'); + $routeGETPOST = Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/c'); + + try { + $routeGETPOST->alias('/b-old'); + $this->fail('Expected duplicate route alias exception.'); + } catch (\Exception $exception) { + $this->assertSame('Route for (POST:b-old) already registered.', $exception->getMessage()); + } + + $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/b-old')); + $this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/b-old')?->route); + } + + public function testCannotRegisterDuplicateRouteMethod(): void + { + $routePOST = new Route(Http::REQUEST_METHOD_POST, '/userinfo'); + Router::addRoute($routePOST); + + try { + Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/userinfo'); + $this->fail('Expected duplicate route exception.'); + } catch (\Exception $exception) { + $this->assertSame('Route for (POST:userinfo) already registered.', $exception->getMessage()); + } + + $routes = Router::getRoutes(); + $this->assertArrayNotHasKey('userinfo', $routes[Http::REQUEST_METHOD_GET]); + + $routeGET = Http::routes(Http::REQUEST_METHOD_GET, '/userinfo'); + + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/userinfo')?->route); + $this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/userinfo')?->route); + } + + public function testCanOverrideRouteMethod(): void + { + Router::setAllowOverride(true); + + try { + $routePOST = new Route(Http::REQUEST_METHOD_POST, '/userinfo'); + Router::addRoute($routePOST); + + $routeGET = Http::routes([ + Http::REQUEST_METHOD_GET, + Http::REQUEST_METHOD_POST, + Http::REQUEST_METHOD_POST, + ], '/userinfo'); + + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/userinfo')?->route); + } finally { + Router::setAllowOverride(false); + } + } + + public function testCannotRegisterRouteForUnknownMethod(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Method (TRACE) not supported.'); + Http::routes([Http::REQUEST_METHOD_GET, 'TRACE'], '/userinfo'); + } + + public function testUnknownMethodDoesNotPartiallyRegisterRoute(): void + { + try { + Http::routes([Http::REQUEST_METHOD_GET, 'TRACE'], '/userinfo'); + $this->fail('Expected unknown method exception.'); + } catch (\Exception $exception) { + $this->assertSame('Method (TRACE) not supported.', $exception->getMessage()); + } + + $routes = Router::getRoutes(); + $this->assertArrayNotHasKey('userinfo', $routes[Http::REQUEST_METHOD_GET]); + + $route = Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/userinfo'); + + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/userinfo')?->route); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_POST, '/userinfo')?->route); + } + + public function testCannotRegisterRouteWithoutMethods(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('At least one HTTP method is required.'); + Http::routes([], '/userinfo'); + } + public function testCanMatchFilename(): void { $routeGET = new Route(Http::REQUEST_METHOD_GET, '/robots.txt');