AutoRoute

Automatically maps HTTP requests to PHP action classes.

Github stars Tracking Chart

AutoRoute

AutoRoute automatically maps incoming HTTP requests (by verb and path) to PHP
action classes in a specified namespace, reflecting on a specified action method
within that class to determine the dynamic URL parameters. In addition, it lets
you generate URL paths based on action class names, and checks the dynamic
segment typehints for you automatically.

AutoRoute is low-maintenance. Merely adding a class to your source code, in the
recognized namespace and with the recognized action method name, automatically
makes it available as a route. No more managing a routes file to keep it in
sync with your action classes!

AutoRoute is fast. In fact, it is faster than FastRoute in common
cases -- even when FastRoute is using cached route definitions.

Note:

When comparing alternatives, please consider AutoRoute as being in the same
category as AltoRouter,
FastRoute,
Klein, etc.,
and not of Aura,
Laravel,
Symfony,
Zend, etc.

Contents

Motivation

Regular-expression (regex) routers generally duplicate important information
that can be found by reflection instead. If you change the action method
parameters targeted by a route, you need to change the route regex itself as
well. As such, regex router usage may be considered a violation of the DRY
principle. For systems with only a few routes, maintaining a routes file as
duplicated information is not such a chore. But for systems with a hundred or
more routes, keeping the routes in sync with their target action classes and
methods can be onerous.

Similarly, annotation-based routers place routing instructions in comments,
often duplicating dynamic parameters that are already present in explicit
method signatures.

As an alternative to regex and annotation-based routers, this router
implementation eliminates the need for route definitions by automatically
mapping the HTTP action class hierarchy to the HTTP method verb and URL path,
reflecting on typehinted action method parameters to determine the dynamic
portions of the URL. It presumes that the action class names conform to a
well-defined convention, and that the action method parameters indicate the
dynamic portions of the URL. This makes the implementation both flexible and
relatively maintenance-free.

Examples

Given a base namespace of App\Http and a base url of /, this request ...

GET /photos

... auto-routes to the class App\Http\Photos\GetPhotos.

Likewise, this request ...

POST /photo

... auto-routes to the class App\Http\Photo\PostPhoto.

Given an action class with method parameters, such as this ...

namespace App\Http\Photo;

class GetPhoto
{
    public function __invoke(int $photoId)
    {
        // ...
    }
}

... the following request will route to it ...

GET /photo/1

... recognizing that 1 should be the value of $photoId.

AutoRoute supports static "tail" parameters on the URL. If the URL ends in a
path segment that matches the underscore-separated tail portion of a class name,
and the action class method has the same number and type of parameters as its
parent or grandparent class, it will route to that class name. For example,
given an action class with method parameters such as this ...

namespace App\Http\Photo\Edit;

class GetPhotoEdit // parent: GetPhoto
{
    public function __invoke(int $photoId)
    {
        // ...
    }
}

... the following request will route to it:

GET /photo/1/edit

Finally, a request for the root URL ...

GET /

... auto-routes to the class App\Http\Get.

How It Works

Class File Naming

Action class files are presumed to be named according to PSR-4 standards;
further:

  1. The class name starts with the HTTP verb it responds to;

  2. Followed by the concatenated names of preceding subnamespaces;

  3. Ending in .php.

Thus, given a base namespace of App\Http, the class App\Http\Photo\PostPhoto
will be the action for POST /photo[/*].

Likewise, App\Http\Photos\GetPhotos will be the action class for GET /photos[/*].

And App\Http\Photo\Edit\GetPhotoEdit will be the action class for GET /photo[/*]/edit.

Finally, at the URL root path, App\Http\Get will be the action class for GET /.

Dynamic Parameters

The action method parameter typehints are honored by the Router. For example,
the following action ...

namespace App\Http\Photos\Archive;

class GetPhotosArchive
{
    public function __invoke(int $year = null, int $month = null)
    {
        // ...
    }
}

... will respond to the following:

GET /photos/archive
GET /photos/archive/1970
GET /photos/archive/1970/08

... but not to the following ...

GET /photos/archive/z
GET /photos/archive/1970/z

... because z is not recognized as an integer. (More finely-tuned validations
of the method parameters must be accomplished in the action method itself, or
more preferably in the domain logic, and cannot be intuited by the Router.)

The Router can recognize typehints of int, float, string, bool, and
array.

For bool, the Router will case-insensitively cast these URL segment values
to true: 1, t, true, y, yes. Similarly, it will case-insensitively cast
these URL segment values to false: 0, f, false, n, no.

For array, the Router will use str_getcsv() on the URL segment value to
generate an array. E.g., an array typehint for a segment value of a,b,c will
receive ['a', 'b', 'c'].

Finally, trailing variadic parameters are also honored by the Router. Given an
action method like the following ...

namespace App\Http\Photos\ByTag;

class GetPhotosByTag
{
    public function __invoke(string $tag, string ...$tags)
    {
        // ...
    }
}

... the Router will honor this request ...

GET /photos/by-tag/foo/bar/baz/

... and recognize the method parameters as __invoke('foo', 'bar', 'baz').

Extended Example

By way of an extended example, these classes would be routed to by these URLs:

App/
    Http/
        Get.php                     GET /               (root)
        Photos/
            GetPhotos.php           GET /photos         (browse/index)
        Photo/
            DeletePhoto.php         DELETE /photo/1     (delete)
            GetPhoto.php            GET /photo/1        (read)
            PatchPhoto.php          PATCH /photo/1      (update)
            PostPhoto.php           POST /photo         (create)
            Add/
                GetPhotoAdd.php     GET /photo/add      (form for creating)
            Edit/
                GetPhotoEdit.php    GET /photo/1/edit   (form for updating)

Usage

Instantiate the AutoRoute container class with the top-level HTTP action
namespace and the directory path to classes in that namespace:

use AutoRoute\AutoRoute;

$autoRoute = new AutoRoute(
    'App\Http',
    dirname(__DIR__) . '/src/App/Http/'
);

Then, pull a new Router out of the container ...

$router = $autoRoute->newRouter();

... and call route() with the HTTP request method verb and the path string to
get back a Route, catching exceptions along the way:

try {
    $route = $router->route($request->method, $request->url[PHP_URL_PATH]);

} catch (\AutoRoute\InvalidNamespace $e) {
    // 400 Bad Request

} catch (\AutoRoute\InvalidArgument $e) {
    // 400 Bad Request

} catch (\AutoRoute\NotFound $e) {
    // 404 Not Found

} catch (\AutoRoute\MethodNotAllowed $e) {
    // 405 Method Not Allowed

}

Finally, dispatch to the action class method using the returned Route
information:

// presuming a DI-based Factory that can create new action class instances:
$action = Factory::newInstance($route->class);

// call the action instance with the method and params,
// presumably getting back an HTTP Response
$response = call_user_func([$action, $route->method], ...$route->params);

Generating Route Paths

Using the AutoRoute container, pull out a new Generator:

$generator = $autoRoute->newGenerator();

Then call the generate() method with the action class name, along with any
action method parameters as variadic arguments:

use App\Http\Photo\Edit\GetPhotoEdit;
use App\Http\Photos\ByTag\GetPhotosByTag;

$path = $generator->generate(GetPhotoEdit::CLASS, 1);
// /photo/1/edit

$path = $generator->generate(GetPhotosByTag::CLASS, 'foo', 'bar', 'baz');
// /photos/by-tag/foo/bar/baz

Tip:

Using the action class name for the route name means that all routes in
AutoRoute are automatically named routes.

The Generator will automatically check the argument values against the action
method signature to make sure the values will be recognized by the Router.
This means that you cannot (or at least, should not!) be able to generate a
path that the Router will not recognize.

Alternative Configurations

Set these options on the AutoRoute container before pulling out a new
Router or Generator.

Class Name Suffix

If your code base gives all action class names the same suffix, such as
"Action", you can tell AutoRoute to disregard that suffix like so:

$autoRoute->setSuffix('Action');

The Router and Generator will now ignore the suffix portion of the class
name.

Method

If you use an action method name other than __invoke(), such as exec() or
run(), you can tell AutoRoute to reflect on its parameters instead:

$autoRoute->setMethod('exec');

The Router and Generator will now examine the exec() method to determine
the dynamic segments of the URL path.

Base URL

You may specify a base URL (i.e., a URL path prefix) like so:

$autoRoute->setBaseUrl('/api');

The Router will ignore the base URL when determining the target action class
for the route, and the Generator will prefix all paths with the base URL.

Word Separator

By default, the Router and Generator will inflect static URL path segments
from foo-bar to FooBar, using the dash as a word separator. If you want to
use a different word separator, such as an underscore, you may do so like this:

$autoRoute->setWordSeparator('_');

This will cause the Router and Generator to inflect from foo_bar to
FooBar (and back again).

Ignoring Action Method Parameters

Some UI systems may use a shared Request object, in which case it is easy to
inject the Request into the action constructor. However, other systems may
not have access to a shared Request object, or may be using a Request that is
fully-formed only at the moment the Action is called, so it must be passed in
some way other than via the constructor.

Typically, these kinds of parameters are passed at the moment the action is
called, which means they must be part of the aciton method signature. However,
AutoRoute will see that parameter and incorrectly interpret it as a dynamic
segment; for example:

class PatchPhoto
{
    public function __invoke(\ServerRequest $request, int $id)
    {
        // ...
    }
}

To remedy this, AutoRoute can skip over any number of leading parameters
on the action method. To do so, set the number of parameters to ignore at the
AutoRoute container ...

$autoRoute->setIgnoreParams(1);

... and then any new Router and Generator will ignore the first parameter.

Note that you will need to pass that first parameter yourself when you invoke
the action:

// determine the route
$route = $router->route($request->method, $request->url[PHP_URL_PATH]);

// create the action object
$action = Factory::newInstance($route->class);

// pass the request first, then any route params
$response = call_user_func($action, $route->method, $request, ...$route->params);

Dumping All Routes

You can dump a list of all recognized routes, and their target action classes,
using the bin/autoroute-dump.php command line tool. Pass the base HTTP action
namespace, and the directory where the action classes are stored:

$ php bin/autoroute-dump.php App\\Http ./src/Http

The output will look something like this:

GET     /
        App\Http\Get
POST    /photo
        App\Http\Photo\PostPhoto
GET     /photo/add
        App\Http\Photo\Add\GetPhotoAdd
DELETE  /photo/{int:id}
        App\Http\Photo\DeletePhoto
GET     /photo/{int:id}
        App\Http\Photo\GetPhoto
PATCH   /photo/{int:id}
        App\Http\Photo\PatchPhoto
GET     /photo/{int:id}/edit
        App\Http\Photo\Edit\GetPhotoEdit
GET     /photos/archive[/{int:year}][/{int:month}][/{int:day}]
        App\Http\Photos\Archive\GetPhotosArchive
GET     /photos[/{int:page}]
        App\Http\Photos\GetPhotos

You can specify alternative configurations with these command line options:

  • --base-url= to set the base URL
  • --ignore-params= to ignore a number of leading method parameters
  • --method= to set the action class method name
  • --suffix= to note a standard action class suffix
  • --word-separator= to specify an alternative word separator

Creating Classes From Routes

AutoRoute provides minimalist support for creating class files based on a
route verb and path, using a template.

To do so, invoke autoroute-create.php with the base namespace, the directory
for that namespace, the HTTP verb, and the URL path with parameter token
placeholders.

For example, the following command ...

$ php bin/autoroute-create.php App\\Http ./src/Http GET /photo/{photoId}

... will create this class file at ./src/Http/Photo/GetPhoto.php:

namespace App\Http\Photo;

class GetPhoto
{
    public function __invoke($photoId)
    {
    }
}

The command will not overwrite existing files.

You can specify alternative configurations with these command line options:

  • --method= to set the action class method name
  • --suffix= to note a standard action class suffix
  • --template= to specify the path to a custom template
  • --word-separator= to specify an alternative word separator

The default class template file is resources/templates/action.tpl. If you
decide to write a custom template of your own, the available string-replacement
placeholders are:

  • {NAMESPACE}
  • {CLASS}
  • {METHOD}
  • {PARAMETERS}

These names should be self-explanatory.

Note:

Even with a custom template, you will almost certainly need to edit the new
file to add a constructor, typehints, default values, and so on. The file
creation functionality is necessarily minimalist, and cannot account for all
possible variability in your specific situation.

Questions and Recipes

Child Resources

N.b.: Deeply-nested child resources are currently considered a poor practice,
but they are common enough that they demand attention here.

Deeply-nested child resources are supported, but their action class method
parameters must conform to a "routine" signature, so that the Router and
Generator can recognize which segments of the URL are dynamic and which are
static.

  1. A child resource action MUST have at least the same number and type of
    parameters as its "parent" resource action; OR, in the case of static tail
    parameter actions, exactly the same number and type or parameters as its
    "grandparent" resource action. (If there is no parent or grandparent resource
    action, then it need not have any parameters.)

  2. A child resource action MAY add parameters after that, either as required or
    optional.

  3. When the URL path includes any of the optional parameter segments, routing to
    further child resource actions beneath it will be terminated.

Tip:

The above terms "parent" and "grandparent" are used in the URL path sense,
not in the class hierarchy sense.

/* GET /company/{companyId} # get an existing company */
namespace App\Http\Company;

class GetCompany // no parent resource
{
    public function __invoke(int $companyId)
    {
        // ...
    }
}

/* POST /company # add a new company*/
class PostCompany // no parent resource
{
    public function __invoke()
    {
        // ...
    }
}

/* PATCH /company/{companyId} # edit an existing company */
class PatchCompany // no parent resource
{
    public function __invoke(int $companyId)
    {
        // ...
    }
}

/* GET /company/{companyId}/employee/{employeeNum} # get an existing company employee */
namespace App\Http\Company\Employee;

class GetCompanyEmployee // parent resource: GetCompany
{
    public function __invoke(int $companyId, int $employeeNum)
    {
        // ...
    }
}

/* POST /company/{companyId}/employee # add a new company employee */
namespace App\Http\Company\Employee;

class PostCompanyEmployee // parent resource: PostCompany
{
    public function __invoke(int $companyId)
    {
        // ...
    }
}

/* PATCH /company/{companyId}/employee/{employeeNum} # edit an existing company employee */
namespace App\Http\Company\Employee;

class PatchCompanyEmployee // parent resource: PatchCompany
{
    public function __invoke(int $companyId, int $employeeNum)
    {
        // ...
    }
}

Fine-Grained Input Validation

Q: How do I specify something similar to the regex route path('/foo/{id}')->token(['id' => '\d{4}']) ?

A: You don't.

Your domain does fine validation of the inputs, not your routing system (coarse
validation only). AutoRoute, in casting the params to arguments, will set the
type on the argument, which may raise an InvalidArgument or NotFound
exception if the value cannot be typecast correctly.

For example, in the action:

namespace App\Http\Photos\Archive;

class GetPhotosArchive
{
    public function __invoke(
        int $year = null,
        int $month = null,
        int $day = null
    ) : \ServerResponse
    {
        $payload = $this->domain->fetchAllBySpan($year, $month, $day);
        return $this->responder->response($payload);
    }
}

Then, in the domain:

namespace App\Domain;

class PhotoService
{
    public function fetchAllBySpan(
        ?int $year = null,
        ?int $month = null,
        ?int $day = null
    ) : Payload
    {
        $select = $this->atlas
            ->select(Photos::class)
            ->orderBy('year DESC', 'month DESC', 'day DESC');

        if ($year !== null) {
            $select->where('year = ', $year);
        }

        if ($month !== null) {
            $select->where('month = ', $month);
        }

        if ($day !== null) {
            $select->where('day = ', $day);
        }

        $result = $select->fetchRecordSet();
        if ($result->isEmpty()) {
            return Payload::notFound();
        }

        return Payload::found($result);
    }
}

Capturing Other Request Values

Q: How to capture the hostname? Headers? Query parameters? Body?

A: Read them from your Request object.

For example, in the action:

namespace App\Http\Foos;

class GetFoos
{
    public function __construct(
        \ServerRequest $request,
        FooService $fooService
    ) {
        $this->request = $request;
        $this->fooService = $fooService;
    }

    public function __invoke(int $fooId)
    {
        $host = $this->request->headers['host'] ?? null;
        $bar = $this->request->get['bar'] ?? null;
        $body = json_decode($this->request->content, true) ?? [];

        $payload = $this->fooService->fetch($host, $foo, $body);
        // ...
    }
}

Then, in the domain:

namespace App\Domain;

class FooService
{
    public function fetch(int $fooId, string $host, string $bar, array $body)
    {
        // ...
    }
}

Main metrics

Overview
Name With Ownerpmjones/AutoRoute
Primary LanguagePHP
Program languagePHP (Language Count: 2)
Platform
License:MIT License
所有者活动
Created At2019-04-06 14:56:03
Pushed At2023-04-19 20:32:41
Last Commit At
Release Count6
Last Release Name2.1.1 (Posted on )
First Release Name1.0.0 (Posted on )
用户参与
Stargazers Count197
Watchers Count12
Fork Count14
Commits Count91
Has Issues Enabled
Issues Count9
Issue Open Count2
Pull Requests Count7
Pull Requests Open Count1
Pull Requests Close Count0
项目设置
Has Wiki Enabled
Is Archived
Is Fork
Is Locked
Is Mirror
Is Private