fizk/router

A very simple PSR-7 Router

2.1.0 2023-02-16 05:49 UTC

This package is auto-updated.

Last update: 2024-04-16 08:09:21 UTC


README

A very simple PRS-7 compatible router.

How it works.

This Router doesn't try to be smart or clever. It doesn't have an opinion on what's it's routing. It doesn't re-write a route expression into RegExp, instead, it wants the expression to be expressed in a Regular Expression upfront. This allows greater control on what will be match against a URI.

This Router is constructed as a tree. This makes the Router that just a little bit faster as it doesn't need to go through a whole list (and every route definition) to find a match.

2 3 4 1

How to use it.

Define a Route, give it a name, an Expression and the Parameters you want returned when the Route is a match, then match it against a PSR-7 Request

use Fizk\Router\Route;
use Laminas\Diactoros\Request;

$routes = new Route(
    'root',
    '/path/(?<id>\d+)',
    ['handler' => SomeHandler::class]
);

$request = new Request('http://this.is/path/1');

$match = $routes->match($request);

print_r($match->getAttributes())
// Will print
//[
//  'id' => '1'
//]

print_r($match->getParams())
// Will print
//[
//  'handler' => 'Namespace\\SomeHandler'
//]

As you can see, there is none of that /path/:id syntax, instead you need to write the full expression. If you want to capture the Attributes in the URI, you have to give them a name by using Named Captures

Nested routes.

Let's say we have a root path: /path and then we can have either numbers or letters as Attributes and we want different handlers/controller to run depending on which type Attribute is provided. We can express it like this:

use Fizk\Router\Route;
use Laminas\Diactoros\Request;

$routes = (new Route('path', '/path', []))
    ->addRoute(new Route('letters', '/(?<id>[a-z]+)', ['controller' => SomeLetterController::class]))
    ->addRoute(new Route('number', '/(?<slug>\d+)', ['controller' => SomeNumberController::class]))
    ;

echo $routes->match(new Request('http://this.is/path/1'))->getParam('handler');
// Will print
// Namespace\\SomeNumberController

echo $routes->match(new Request('http://this.is/path/arg'))->getParam('handler');
// Will print
// Namespace\\SomeLetterController

Routes can be nested "infinitely" deep.

The Router Class.

Defining routes with the ->addRoute(...) syntax can be a bit verbose. This library provides a class than can take in configuration as an array and build the Router Tree, that way the router configuration is a little bit simpler to manage.

// router.config.php
return [
    'base' => [
        'pattern' => '/',
        'options' => ['handler' => 'IndexHandler'],
    ],
    'albums' => [
        'pattern' => '/albums',
        'options' => ['handler' => 'AlbumsHandler'],
        'children' => [
            'album' => [
                'pattern' => '/(?<id>\d+)',
                'options' => ['handler' => 'AlbumHandler'],
            ],
        ]
    ],
];
// index.php

$router = new Router(require './router.config.php');

$request = new Request('http://example.com/albums/1');
echo $router->match($request)->getParams('handler');

// Will print
// AlbumHandler

The array key will become the name of the Route. The required pattern and options keys will be passed to the Route instance. An optional children key can be defined, those routes will become children of the parent route.

Because this class has all the configuration inside of it, it can provide a method called public function construct(string $path, ?array $arguments = []): ?string; It can construct a URI based off the names you have given to the Routes. An example of this would be:

$config = [
    'index' => [
        'pattern' => '/',
        'options' => ['handler' => 'IndexHandler'],
    ],
    'albums' => [
        'pattern' => '/albums',
        'options' => ['handler' => 'AlbumsHandler'],
        'children' => [
            'album' => [
                'pattern' => '/(?<id>\d+)',
                'options' => ['handler' => 'AlbumHandler'],
            ],
        ]
    ],
];

$router = new Router($config);
echo $router->construct('albums/album', ['id' => 1]);

// This will print
//  /albums/1

Example

This examples uses Fizk\Router in conjunction with Psr\Http\Message\ResponseInterface and Psr\Http\Message\ServerRequestInterface. What is important to understand is that the Router is not going in inject any values from the URI into the $responce object or invoke the Controller/Handler. These are things you have to manage on your own.

The benefit of this is that the Router is not dependent on how Controllers/Handlers are implemented or which PSR standard it is using.

use Fizk\Router\Route;
use Laminas\Diactoros\Request;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

//Define Handlers/Controllers
class SomeNumberController implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $id = $request->getAttribute('id');
        $data = $service->getById($id);
        return new JsonResponse($data);
    }
}

class SomeLetterController implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $slug = $request->getAttribute('slug');
        $data = $service->getBySlug($slug);
        return new JsonResponse($slug);
    }
}

class ResourceNotFoundController implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return new JsonResponse(['message' => 'Resource Not Found'], 404);
    }
}

// Create a Request Object, pulling CGI values from global scope
$request = ServerRequestFactory::fromGlobals(
    $_SERVER,
    $_GET,
    $_POST
    $_COOKIE,
    $_FILES
);
// Define an Emitter, which will set HTTP headers and body before delivering to client
$emitter = new SapiEmitter();

// Define Routes
$routes = (new Route('path', '/path', []))
    ->addRoute(new Route('letters', '/(?<id>[a-z]+)', ['controller' => new SomeLetterController()]))
    ->addRoute(new Route('number', '/(?<slug>\d+)', ['controller' => new SomeNumberController]))
    ;

// ...OR USE THE MORE COMPACT WAY OF DEFINING ROUTES
$routes = new Router([
    'path' => [
        'pattern' => '/path',
        'options' => []
        'children' => [
            'letters' => [
                'pattern' => '/(?<id>[a-z]+)',
                'options' => ['controller' => new SomeLetterController()]
            ],
            'numbers' => [
                'pattern' => '/(?<slug>\d+)',
                'options' => ['controller' => new SomeNumberController()]
            ],
        ]
    ]
]);

// Match Routes against Request Object
$match = $routes->match($request);

if ($match) {
    //Add attributes from URI to the Request Object
    foreach ($match->getAttributes() as $name => $value) {
        $request = $request->withAttribute($name, $value);
    }

    // Run the Handler/Controller
    $response = $match->getParam('controller')->handle($request);

    // Emit a Response back to client
    $emitter->emit($response);
} else {
    // Run the Error Handler/Controller
    $response = (new ResourceNotFoundController())->handle($request);

    // Emit a Response back to client
    $emitter->emit($response);
}