Skip to content

Commit 617411b

Browse files
authored
Add HTTP multi-method routes API (#1)
* Add HTTP multi-method routes API * Simplify HTTP route alias registration
1 parent c26301b commit 617411b

7 files changed

Lines changed: 293 additions & 19 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,36 @@ curl http://localhost:8000/hello-world?name=Appwrite
146146

147147
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.
148148

149+
### Multiple Methods
150+
151+
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.
152+
153+
Use `alias()` to serve the same route under another path, for example to keep a legacy URL working:
154+
155+
```php
156+
Http::get('/users/:id')
157+
->alias('/members/:id')
158+
->param('id', '', new Text(256), 'User ID')
159+
->inject('response')
160+
->action(function(string $id, Response $response) {
161+
$response->json(['id' => $id]);
162+
});
163+
```
164+
165+
Use `routes()` to serve the same route under multiple HTTP methods. For example, the OpenID Connect UserInfo endpoint must support both GET and POST:
166+
167+
```php
168+
Http::routes([Http::REQUEST_METHOD_GET, Http::REQUEST_METHOD_POST], '/oauth/userinfo')
169+
->inject('request')
170+
->inject('response')
171+
->action(function(Request $request, Response $response) {
172+
// $request->getMethod() tells how the request arrived (GET or POST)
173+
$response->json(['sub' => 'user-id']);
174+
});
175+
```
176+
177+
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.
178+
149179
### Hooks
150180

151181
There are three types of hooks:

src/Http/Http.php

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ public function setCompressionSupported(mixed $compressionSupported): void
179179
*/
180180
public static function get(string $url): Route
181181
{
182-
return self::addRoute(self::REQUEST_METHOD_GET, $url);
182+
return self::routes(self::REQUEST_METHOD_GET, $url);
183183
}
184184

185185
/**
@@ -189,7 +189,7 @@ public static function get(string $url): Route
189189
*/
190190
public static function post(string $url): Route
191191
{
192-
return self::addRoute(self::REQUEST_METHOD_POST, $url);
192+
return self::routes(self::REQUEST_METHOD_POST, $url);
193193
}
194194

195195
/**
@@ -199,7 +199,7 @@ public static function post(string $url): Route
199199
*/
200200
public static function put(string $url): Route
201201
{
202-
return self::addRoute(self::REQUEST_METHOD_PUT, $url);
202+
return self::routes(self::REQUEST_METHOD_PUT, $url);
203203
}
204204

205205
/**
@@ -209,7 +209,7 @@ public static function put(string $url): Route
209209
*/
210210
public static function patch(string $url): Route
211211
{
212-
return self::addRoute(self::REQUEST_METHOD_PATCH, $url);
212+
return self::routes(self::REQUEST_METHOD_PATCH, $url);
213213
}
214214

215215
/**
@@ -219,7 +219,37 @@ public static function patch(string $url): Route
219219
*/
220220
public static function delete(string $url): Route
221221
{
222-
return self::addRoute(self::REQUEST_METHOD_DELETE, $url);
222+
return self::routes(self::REQUEST_METHOD_DELETE, $url);
223+
}
224+
225+
/**
226+
* ROUTES
227+
*
228+
* Add one route under one or more request methods
229+
*
230+
* @param string|array<int, string> $methods
231+
*/
232+
public static function routes(string|array $methods, string $url): Route
233+
{
234+
$methods = \is_array($methods) ? $methods : [$methods];
235+
$methods = array_values(array_unique($methods));
236+
237+
if (empty($methods)) {
238+
throw new \Exception('At least one HTTP method is required.');
239+
}
240+
241+
$routes = Router::getRoutes();
242+
243+
foreach ($methods as $method) {
244+
if (!\array_key_exists($method, $routes)) {
245+
throw new \Exception("Method ({$method}) not supported.");
246+
}
247+
}
248+
249+
$route = new Route($methods, $url);
250+
Router::addRoute($route);
251+
252+
return $route;
223253
}
224254

225255
/**

src/Http/Route.php

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
class Route extends Hook
88
{
99
/**
10-
* HTTP Method
10+
* HTTP Methods
11+
*
12+
* @var array<string>
1113
*/
12-
protected string $method = '';
14+
protected array $methods = [];
1315

1416
/**
1517
* Whether to use hook
@@ -28,6 +30,13 @@ class Route extends Hook
2830
*/
2931
protected array $pathParams = [];
3032

33+
/**
34+
* Alias paths this route is also registered under.
35+
*
36+
* @var array<string>
37+
*/
38+
protected array $aliasPaths = [];
39+
3140
/**
3241
* Internal counter.
3342
*/
@@ -38,11 +47,14 @@ class Route extends Hook
3847
*/
3948
protected int $order;
4049

41-
public function __construct(string $method, string $path)
50+
/**
51+
* @param string|array<int, string> $methods
52+
*/
53+
public function __construct(string|array $methods, string $path)
4254
{
4355
parent::__construct();
4456
$this->path($path);
45-
$this->method = $method;
57+
$this->methods = \is_array($methods) ? array_values(array_unique($methods)) : [$methods];
4658
$this->order = ++self::$counter;
4759
}
4860

@@ -71,6 +83,10 @@ public function alias(string $path): self
7183
{
7284
Router::addRouteAlias($path, $this);
7385

86+
if (!\in_array($path, $this->aliasPaths, true)) {
87+
$this->aliasPaths[] = $path;
88+
}
89+
7490
return $this;
7591
}
7692

@@ -85,11 +101,13 @@ public function hook(bool $hook = true): self
85101
}
86102

87103
/**
88-
* Get HTTP Method
104+
* Get primary HTTP method.
105+
*
106+
* @deprecated Use getMethods() instead.
89107
*/
90108
public function getMethod(): string
91109
{
92-
return $this->method;
110+
return $this->methods[0] ?? '';
93111
}
94112

95113
/**
@@ -108,6 +126,16 @@ public function getHook(): bool
108126
return $this->hook;
109127
}
110128

129+
/**
130+
* Get HTTP methods this route is registered under.
131+
*
132+
* @return array<string>
133+
*/
134+
public function getMethods(): array
135+
{
136+
return $this->methods;
137+
}
138+
111139
/**
112140
* Set path param.
113141
*/

src/Http/Router.php

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,41 @@ public static function setAllowOverride(bool $value): void
7474
public static function addRoute(Route $route): void
7575
{
7676
[$path, $params] = self::preparePath($route->getPath());
77+
$methods = $route->getMethods();
78+
$method = $methods[0] ?? '';
79+
$additionalMethods = \array_slice($methods, 1);
7780

78-
if (!\array_key_exists($route->getMethod(), self::$routes)) {
79-
throw new Exception("Method ({$route->getMethod()}) not supported.");
81+
if (!\array_key_exists($method, self::$routes)) {
82+
throw new Exception("Method ({$method}) not supported.");
83+
}
84+
85+
if (\array_key_exists($path, self::$routes[$method]) && !self::$allowOverride) {
86+
throw new Exception("Route for ({$method}:{$path}) already registered.");
8087
}
8188

82-
if (\array_key_exists($path, self::$routes[$route->getMethod()]) && !self::$allowOverride) {
83-
throw new Exception("Route for ({$route->getMethod()}:{$path}) already registered.");
89+
foreach ($additionalMethods as $additionalMethod) {
90+
if (!\array_key_exists($additionalMethod, self::$routes)) {
91+
throw new Exception("Method ({$additionalMethod}) not supported.");
92+
}
93+
94+
if ($route->getPath() === '') {
95+
throw new Exception('Additional route methods are not supported for the wildcard route.');
96+
}
97+
98+
if (\array_key_exists($path, self::$routes[$additionalMethod]) && !self::$allowOverride) {
99+
throw new Exception("Route for ({$additionalMethod}:{$path}) already registered.");
100+
}
84101
}
85102

86103
foreach ($params as $key => $index) {
87104
$route->setPathParam($key, $index, $path);
88105
}
89106

90-
self::$routes[$route->getMethod()][$path] = $route;
107+
self::$routes[$method][$path] = $route;
108+
109+
foreach ($additionalMethods as $additionalMethod) {
110+
self::$routes[$additionalMethod][$path] = $route;
111+
}
91112
}
92113

93114
/**
@@ -97,17 +118,26 @@ public static function addRoute(Route $route): void
97118
*/
98119
public static function addRouteAlias(string $path, Route $route): void
99120
{
121+
$methods = $route->getMethods();
100122
[$alias, $params] = self::preparePath($path);
101123

102-
if (\array_key_exists($alias, self::$routes[$route->getMethod()]) && !self::$allowOverride) {
103-
throw new Exception("Route for ({$route->getMethod()}:{$alias}) already registered.");
124+
foreach ($methods as $method) {
125+
if (!\array_key_exists($method, self::$routes)) {
126+
throw new Exception("Method ({$method}) not supported.");
127+
}
128+
129+
if (\array_key_exists($alias, self::$routes[$method]) && !self::$allowOverride) {
130+
throw new Exception("Route for ({$method}:{$alias}) already registered.");
131+
}
104132
}
105133

106134
foreach ($params as $key => $index) {
107135
$route->setPathParam($key, $index, $alias);
108136
}
109137

110-
self::$routes[$route->getMethod()][$alias] = $route;
138+
foreach ($methods as $method) {
139+
self::$routes[$method][$alias] = $route;
140+
}
111141
}
112142

113143
/**
@@ -232,6 +262,7 @@ public static function reset(): void
232262
{
233263
self::$params = [];
234264
self::$wildcard = null;
265+
self::$allowOverride = false;
235266
self::$routes = [
236267
Http::REQUEST_METHOD_GET => [],
237268
Http::REQUEST_METHOD_POST => [],

tests/HttpTest.php

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

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

tests/RouteTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public function setUp(): void
1919
public function testCanGetMethod(): void
2020
{
2121
$this->assertSame('GET', $this->route->getMethod());
22+
$this->assertSame(['GET'], $this->route->getMethods());
2223
}
2324

2425
public function testCanGetAndSetPath(): void

0 commit comments

Comments
 (0)