Skip to content

Commit 7618804

Browse files
committed
(feat): method aliases for routes via aliasMethod()
1 parent ec968c1 commit 7618804

5 files changed

Lines changed: 257 additions & 4 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,37 @@ curl http://localhost:8000/hello-world?name=Appwrite
143143

144144
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.
145145

146+
### Aliases
147+
148+
A route can be registered under additional paths and HTTP methods using aliases. All aliases dispatch to the same route, so the action, params, and hooks are defined only once.
149+
150+
Use `alias()` to serve the same route under another path, for example to keep a legacy URL working:
151+
152+
```php
153+
Http::get('/users/:id')
154+
->alias('/members/:id')
155+
->param('id', '', new Text(256), 'User ID')
156+
->inject('response')
157+
->action(function(string $id, Response $response) {
158+
$response->json(['id' => $id]);
159+
});
160+
```
161+
162+
Use `aliasMethod()` to serve the same route under another HTTP method. For example, the OpenID Connect UserInfo endpoint must support both GET and POST:
163+
164+
```php
165+
Http::get('/oauth/userinfo')
166+
->aliasMethod(Http::REQUEST_METHOD_POST)
167+
->inject('request')
168+
->inject('response')
169+
->action(function(Request $request, Response $response) {
170+
// $request->getMethod() tells how the request arrived (GET or POST)
171+
$response->json(['sub' => 'user-id']);
172+
});
173+
```
174+
175+
Path and method aliases combine: a route with both responds on every method under every path. Note that `getMethod()` on the route keeps returning the primary method it was defined with; use the request resource to tell how a request arrived.
176+
146177
### Hooks
147178

148179
There are three types of hooks:

src/Http/Route.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ class Route extends Hook
2828
*/
2929
protected array $pathParams = [];
3030

31+
/**
32+
* Alias paths this route is also registered under.
33+
*
34+
* @var array<string>
35+
*/
36+
protected array $aliasPaths = [];
37+
38+
/**
39+
* Alias HTTP methods this route is also registered under.
40+
*
41+
* @var array<string>
42+
*/
43+
protected array $aliasMethods = [];
44+
3145
/**
3246
* Internal counter.
3347
*/
@@ -71,6 +85,31 @@ public function alias(string $path): self
7185
{
7286
Router::addRouteAlias($path, $this);
7387

88+
foreach ($this->aliasMethods as $method) {
89+
Router::addRouteAlias($path, $this, $method);
90+
}
91+
92+
$this->aliasPaths[] = $path;
93+
94+
return $this;
95+
}
96+
97+
/**
98+
* Register this route under an additional HTTP method.
99+
*
100+
* The route keeps reporting its primary method via getMethod();
101+
* use the request's method to tell how a request arrived.
102+
*/
103+
public function aliasMethod(string $method): self
104+
{
105+
Router::addRouteMethodAlias($method, $this);
106+
107+
foreach ($this->aliasPaths as $path) {
108+
Router::addRouteAlias($path, $this, $method);
109+
}
110+
111+
$this->aliasMethods[] = $method;
112+
74113
return $this;
75114
}
76115

@@ -108,6 +147,26 @@ public function getHook(): bool
108147
return $this->hook;
109148
}
110149

150+
/**
151+
* Get alias paths.
152+
*
153+
* @return array<string>
154+
*/
155+
public function getAliasPaths(): array
156+
{
157+
return $this->aliasPaths;
158+
}
159+
160+
/**
161+
* Get alias methods.
162+
*
163+
* @return array<string>
164+
*/
165+
public function getAliasMethods(): array
166+
{
167+
return $this->aliasMethods;
168+
}
169+
111170
/**
112171
* Set path param.
113172
*/

src/Http/Router.php

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,19 +95,53 @@ public static function addRoute(Route $route): void
9595
*
9696
* @throws \Exception
9797
*/
98-
public static function addRouteAlias(string $path, Route $route): void
98+
public static function addRouteAlias(string $path, Route $route, ?string $method = null): void
9999
{
100+
$method ??= $route->getMethod();
101+
102+
if (!\array_key_exists($method, self::$routes)) {
103+
throw new Exception("Method ({$method}) not supported.");
104+
}
105+
100106
[$alias, $params] = self::preparePath($path);
101107

102-
if (\array_key_exists($alias, self::$routes[$route->getMethod()]) && !self::$allowOverride) {
103-
throw new Exception("Route for ({$route->getMethod()}:{$alias}) already registered.");
108+
if (\array_key_exists($alias, self::$routes[$method]) && !self::$allowOverride) {
109+
throw new Exception("Route for ({$method}:{$alias}) already registered.");
104110
}
105111

106112
foreach ($params as $key => $index) {
107113
$route->setPathParam($key, $index, $alias);
108114
}
109115

110-
self::$routes[$route->getMethod()][$alias] = $route;
116+
self::$routes[$method][$alias] = $route;
117+
}
118+
119+
/**
120+
* Register a route under an additional HTTP method, using its own path.
121+
*
122+
* @throws \Exception
123+
*/
124+
public static function addRouteMethodAlias(string $method, Route $route): void
125+
{
126+
if (!\array_key_exists($method, self::$routes)) {
127+
throw new Exception("Method ({$method}) not supported.");
128+
}
129+
130+
if ($route->getPath() === '') {
131+
throw new Exception('Method aliases are not supported for the wildcard route.');
132+
}
133+
134+
[$path, $params] = self::preparePath($route->getPath());
135+
136+
if (\array_key_exists($path, self::$routes[$method]) && !self::$allowOverride) {
137+
throw new Exception("Route for ({$method}:{$path}) already registered.");
138+
}
139+
140+
foreach ($params as $key => $index) {
141+
$route->setPathParam($key, $index, $path);
142+
}
143+
144+
self::$routes[$method][$path] = $route;
111145
}
112146

113147
/**

tests/HttpTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,32 @@ public function testCanExecuteRoute(): void
250250
$this->assertSame('init-' . $resource . '-(init-homepage)-param-x*param-y-(shutdown-homepage)-shutdown', $result);
251251
}
252252

253+
public function testCanExecuteRouteWithMethodAlias(): void
254+
{
255+
$route = Http::get('/v1/oauth/userinfo');
256+
257+
$route
258+
->aliasMethod(Http::REQUEST_METHOD_POST)
259+
->inject('request')
260+
->action(function ($request) {
261+
echo 'userinfo:' . $request->getMethod();
262+
});
263+
264+
$_SERVER['REQUEST_URI'] = '/v1/oauth/userinfo';
265+
266+
$_SERVER['REQUEST_METHOD'] = 'GET';
267+
ob_start();
268+
$this->http->run(new Request(), new Response());
269+
$result = ob_get_clean();
270+
$this->assertSame('userinfo:GET', $result);
271+
272+
$_SERVER['REQUEST_METHOD'] = 'POST';
273+
ob_start();
274+
$this->http->run(new Request(), new Response());
275+
$result = ob_get_clean();
276+
$this->assertSame('userinfo:POST', $result);
277+
}
278+
253279
public function testCanAddAndExecuteHooks(): void
254280
{
255281
Http::setAllowOverride(true);

tests/RouterTest.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
final class RouterTest extends TestCase
1010
{
11+
public function setUp(): void
12+
{
13+
Router::setAllowOverride(false);
14+
}
15+
1116
public function tearDown(): void
1217
{
1318
Router::reset();
@@ -137,6 +142,104 @@ public function testCanMatchMix(): void
137142
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')?->route);
138143
}
139144

145+
public function testCanMatchMethodAlias(): void
146+
{
147+
$route = new Route(Http::REQUEST_METHOD_GET, '/userinfo');
148+
$route->aliasMethod(Http::REQUEST_METHOD_POST);
149+
150+
Router::addRoute($route);
151+
152+
$this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/userinfo')?->route);
153+
$this->assertEquals($route, Router::match(Http::REQUEST_METHOD_POST, '/userinfo')?->route);
154+
$this->assertNull(Router::match(Http::REQUEST_METHOD_PUT, '/userinfo'));
155+
156+
$this->assertSame(Http::REQUEST_METHOD_GET, $route->getMethod());
157+
$this->assertSame([Http::REQUEST_METHOD_POST], $route->getAliasMethods());
158+
}
159+
160+
public function testCanMatchMethodAliasWithPlaceholder(): void
161+
{
162+
$route = new Route(Http::REQUEST_METHOD_GET, '/users/:id');
163+
$route->aliasMethod(Http::REQUEST_METHOD_POST);
164+
165+
Router::addRoute($route);
166+
167+
$match = Router::match(Http::REQUEST_METHOD_POST, '/users/abc-123');
168+
169+
$this->assertEquals($route, $match?->route);
170+
$this->assertSame(['id' => 'abc-123'], $match?->params);
171+
}
172+
173+
public function testMethodAliasCrossesPathAliasesRegardlessOfOrder(): void
174+
{
175+
$routeA = new Route(Http::REQUEST_METHOD_GET, '/a');
176+
$routeA
177+
->alias('/a-old')
178+
->aliasMethod(Http::REQUEST_METHOD_POST);
179+
180+
Router::addRoute($routeA);
181+
182+
$routeB = new Route(Http::REQUEST_METHOD_GET, '/b');
183+
$routeB
184+
->aliasMethod(Http::REQUEST_METHOD_POST)
185+
->alias('/b-old');
186+
187+
Router::addRoute($routeB);
188+
189+
$this->assertEquals($routeA, Router::match(Http::REQUEST_METHOD_POST, '/a')?->route);
190+
$this->assertEquals($routeA, Router::match(Http::REQUEST_METHOD_POST, '/a-old')?->route);
191+
$this->assertEquals($routeB, Router::match(Http::REQUEST_METHOD_POST, '/b')?->route);
192+
$this->assertEquals($routeB, Router::match(Http::REQUEST_METHOD_POST, '/b-old')?->route);
193+
}
194+
195+
public function testCannotRegisterDuplicateMethodAlias(): void
196+
{
197+
$routePOST = new Route(Http::REQUEST_METHOD_POST, '/userinfo');
198+
Router::addRoute($routePOST);
199+
200+
$routeGET = new Route(Http::REQUEST_METHOD_GET, '/userinfo');
201+
202+
$this->expectException(\Exception::class);
203+
$this->expectExceptionMessage('Route for (POST:userinfo) already registered.');
204+
$routeGET->aliasMethod(Http::REQUEST_METHOD_POST);
205+
}
206+
207+
public function testCanOverrideMethodAlias(): void
208+
{
209+
Router::setAllowOverride(true);
210+
211+
try {
212+
$routePOST = new Route(Http::REQUEST_METHOD_POST, '/userinfo');
213+
Router::addRoute($routePOST);
214+
215+
$routeGET = new Route(Http::REQUEST_METHOD_GET, '/userinfo');
216+
Router::addRoute($routeGET);
217+
$routeGET->aliasMethod(Http::REQUEST_METHOD_POST);
218+
219+
$this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/userinfo')?->route);
220+
} finally {
221+
Router::setAllowOverride(false);
222+
}
223+
}
224+
225+
public function testCannotRegisterMethodAliasForUnknownMethod(): void
226+
{
227+
$route = new Route(Http::REQUEST_METHOD_GET, '/userinfo');
228+
229+
$this->expectException(\Exception::class);
230+
$this->expectExceptionMessage('Method (TRACE) not supported.');
231+
$route->aliasMethod('TRACE');
232+
}
233+
234+
public function testCannotRegisterMethodAliasOnWildcardRoute(): void
235+
{
236+
$route = new Route('', '');
237+
238+
$this->expectException(\Exception::class);
239+
$this->expectExceptionMessage('Method aliases are not supported for the wildcard route.');
240+
$route->aliasMethod(Http::REQUEST_METHOD_POST);
241+
}
242+
140243
public function testCanMatchFilename(): void
141244
{
142245
$routeGET = new Route(Http::REQUEST_METHOD_GET, '/robots.txt');

0 commit comments

Comments
 (0)