Skip to content

Commit

Permalink
Add pattern-based routing implemented as web.Routes
Browse files Browse the repository at this point in the history
  • Loading branch information
thekid committed Apr 6, 2024
1 parent 71e07ac commit 2cde8c7
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 6 deletions.
2 changes: 1 addition & 1 deletion src/main/php/web/Application.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function environment() { return $this->environment; }
*/
public final function routing() {
if (null === $this->routing) {
$routing= Routing::cast($this->routes(), true);
$routing= Routes::cast($this->routes(), true);
$this->routing= $this->filters ? new Filters($this->filters, $routing) : $routing;
}
return $this->routing;
Expand Down
128 changes: 128 additions & 0 deletions src/main/php/web/Routes.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php namespace web;

use web\handler\Call;
use web\routing\CannotRoute;

/**
* Routing takes care of directing the request to the correct target
* by using one or more routes given to it.
*
* @test web.unittest.RoutesTest
*/
class Routes implements Handler {
private $routes= [];
private $top= false;
private $default= null;

/**
* Casts given routes to an instance of `Routes`. The argument may be one of:
*
* - An instance of `Routes`, in which case it is returned directly
* - A map of definitions => handlers, which are passed to `route()`
* - A handler, which becomes the argument to `default()`.
*
* @param web.Handler|web.Application|function(web.Request, web.Response): var|[:var] $routes
* @param bool $top Whether this is the top-level routing
* @return self
*/
public static function cast($routes, $top= false) {
if ($routes instanceof self) {
$r= $routes;
} else if ($routes instanceof Application) {
$r= $routes->routing();
} else if (is_array($routes)) {
$r= new self();
foreach ($routes as $definition => $target) {
$r->route($definition, $target);
}
} else {
$r= (new self())->default($routes);
}

$r->top= $top;
return $r;
}

/** @return [:web.Handler] */
public function routes() { return $this->routes; }

/**
* Matches a given definition, routing it to the specified target.
*
* - `GET` matches GET requests
* - `GET /` matches GET requests to any path
* - `GET /test` matches GET requests inside /test
* - `GET|POST` matches GET and POST requests
* - `/` matches any request to any path
* - `/test` matches any request inside /test
*
* @param string $definition
* @param web.Handler|function(web.Request, web.Response): var $target
* @return self
*/
public function route($definition, $target) {
static $quote= ['#' => '\\#', '.' => '\\.'];

$handler= $target instanceof Handler ? $target : new Call($target);
if ('/' === $definition[0]) {
$this->routes['#^[A-Z]+ '.strtr(rtrim($definition, '/'), $quote).'/#']= $handler;
} else {
sscanf($definition, "%[A-Z|] %[^\r]", $methods, $path);
$this->routes['#^'.$methods.' '.(null === $path ? '' : strtr(rtrim($path, '/'), $quote)).'/#']= $handler;
}
return $this;
}

/**
* Maps all requests not otherwise mapped to a given target.
*
* @param web.Handler|function(web.Request, web.Response): var $target
* @return self
*/
public function default($target) {
$this->default= $target instanceof Handler ? $target : new Call($target);
return $this;
}

/**
* Routes a request to the handler specified by this routing instance's
* routes. Throws a `CannotRoute` error if not route is matched and no
* default route exists.
*
* @param web.Request $request
* @return web.Handler
* @throws web.CannotRoute
*/
public function target($request) {
$match= $request->method().' '.rtrim($request->uri()->path(), '/').'/';
foreach ($this->routes as $pattern => $handler) {
if (preg_match($pattern, $match)) return $handler;
}
if ($this->default) return $this->default;

throw new CannotRoute($request);
}

/**
* Handle a request
*
* @param web.Request $request
* @param web.Response $response
* @return var
*/
public function handle($request, $response) {
$seen= [];

dispatch: $result= $this->target($request)->handle($request, $response);
if ($this->top && $result instanceof Dispatch) {
$seen[$request->uri()->hashCode()]= true;
$request->rewrite($result->uri());
if (isset($seen[$request->uri()->hashCode()])) {
throw new Error(508, 'Internal redirect loop caused by dispatch to '.$result->uri());
}
goto dispatch;
}

return $result;
}
}
3 changes: 2 additions & 1 deletion src/main/php/web/Routing.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* Routing takes care of directing the request to the correct target
* by using one or more routes given to it.
*
* @deprecated Use web.Routes instead!
* @test web.unittest.RoutingTest
*/
class Routing implements Handler {
Expand All @@ -29,7 +30,7 @@ public static function cast($routes, $top= false) {
if ($routes instanceof self) {
$r= $routes;
} else if ($routes instanceof Application) {
$r= $routes->routing();
$r= self::cast($routes->routes(), true);
} else if (is_array($routes)) {
$r= new self();
foreach ($routes as $definition => $target) {
Expand Down
8 changes: 4 additions & 4 deletions src/test/php/web/unittest/ApplicationTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use test\{Assert, Expect, Test, Values};
use util\Objects;
use web\io\{TestInput, TestOutput};
use web\{Application, Environment, Error, Filter, Filters, Handler, Request, Response, Routing};
use web\{Application, Environment, Error, Filter, Filters, Handler, Request, Response, Routes};

class ApplicationTest {
private $environment;
Expand Down Expand Up @@ -69,7 +69,7 @@ public function routes() { /* Implementation irrelevant for this test */ }

#[Test]
public function routing() {
$routing= new Routing();
$routing= new Routes();
$app= newinstance(Application::class, [$this->environment], [
'routes' => function() use($routing) { return $routing; }
]);
Expand All @@ -78,7 +78,7 @@ public function routing() {

#[Test]
public function routes_only_called_once() {
$routing= new Routing();
$routing= new Routes();
$called= 0;
$app= newinstance(Application::class, [$this->environment], [
'routes' => function() use($routing, &$called) {
Expand All @@ -95,7 +95,7 @@ public function routes_only_called_once() {
#[Test]
public function with_routing() {
$this->assertHandled($handled, function() use(&$handled) {
return (new Routing())->fallbacks(function($request, $response) use(&$handled) {
return (new Routes())->default(function($request, $response) use(&$handled) {
$handled[]= [$request, $response];
});
});
Expand Down
1 change: 1 addition & 0 deletions src/test/php/web/unittest/RoutingTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use web\routing\{CannotRoute, Target};
use web\{Application, Environment, Filters, Handler, Request, Response, Route, Routing};

/** @deprecated */
class RoutingTest {
private $handlers;

Expand Down

0 comments on commit 2cde8c7

Please sign in to comment.