Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Note that `getMethod()` on the route returns the first method it was defined with; use the request resource to tell how a request arrived.

### Hooks

There are three types of hooks:
Expand Down
40 changes: 35 additions & 5 deletions src/Http/Http.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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<int, string> $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[0], $url, \array_slice($methods, 1));
Router::addRoute($route);

return $route;
}

/**
Expand Down
38 changes: 37 additions & 1 deletion src/Http/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ class Route extends Hook
*/
protected array $pathParams = [];

/**
* Alias paths this route is also registered under.
*
* @var array<string>
*/
protected array $aliasPaths = [];

/**
* Additional HTTP methods this route is also registered under.
*
* @var array<string>
*/
protected array $additionalMethods = [];

/**
* Internal counter.
*/
Expand All @@ -38,11 +52,15 @@ class Route extends Hook
*/
protected int $order;

public function __construct(string $method, string $path)
/**
* @param array<int, string> $additionalMethods
*/
public function __construct(string $method, string $path, array $additionalMethods = [])
{
parent::__construct();
$this->path($path);
$this->method = $method;
$this->additionalMethods = $additionalMethods;
$this->order = ++self::$counter;
}

Expand Down Expand Up @@ -71,6 +89,14 @@ public function alias(string $path): self
{
Router::addRouteAlias($path, $this);

foreach ($this->additionalMethods as $method) {
Router::addRouteAlias($path, $this, $method);
}

if (!\in_array($path, $this->aliasPaths, true)) {
$this->aliasPaths[] = $path;
}

return $this;
}

Expand Down Expand Up @@ -108,6 +134,16 @@ public function getHook(): bool
return $this->hook;
}

/**
* Get additional methods this route is registered under.
*
* @return array<string>
*/
public function getAdditionalMethods(): array
{
return $this->additionalMethods;
}
Comment thread
ChiragAgg5k marked this conversation as resolved.

/**
* Set path param.
*/
Expand Down
31 changes: 27 additions & 4 deletions src/Http/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,26 +88,48 @@ public static function addRoute(Route $route): void
}

self::$routes[$route->getMethod()][$path] = $route;

foreach ($route->getAdditionalMethods() as $method) {
if (!\array_key_exists($method, self::$routes)) {
throw new Exception("Method ({$method}) 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[$method]) && !self::$allowOverride) {
throw new Exception("Route for ({$method}:{$path}) already registered.");
}

self::$routes[$method][$path] = $route;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

/**
* Add route to router.
*
* @throws \Exception
*/
public static function addRouteAlias(string $path, Route $route): void
public static function addRouteAlias(string $path, Route $route, ?string $method = null): void
{
$method ??= $route->getMethod();

if (!\array_key_exists($method, self::$routes)) {
throw new Exception("Method ({$method}) not supported.");
}

[$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.");
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;
self::$routes[$method][$alias] = $route;
}

/**
Expand Down Expand Up @@ -232,6 +254,7 @@ public static function reset(): void
{
self::$params = [];
self::$wildcard = null;
self::$allowOverride = false;
self::$routes = [
Http::REQUEST_METHOD_GET => [],
Http::REQUEST_METHOD_POST => [],
Expand Down
23 changes: 23 additions & 0 deletions tests/HttpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions tests/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

final class RouterTest extends TestCase
{
public function setUp(): void
{
Router::setAllowOverride(false);
}

public function tearDown(): void
{
Router::reset();
Expand Down Expand Up @@ -137,6 +142,108 @@ 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, $route->getMethod());
}

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);
}

public function testCannotRegisterDuplicateRouteMethod(): void
{
$routePOST = new Route(Http::REQUEST_METHOD_POST, '/userinfo');
Router::addRoute($routePOST);

$this->expectException(\Exception::class);
$this->expectExceptionMessage('Route for (POST:userinfo) already registered.');
Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/userinfo');
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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');
Expand Down