inroutephp/inroute

Generate http routing and dispatching middleware from docblock annotations

1.1.1 2019-12-23 22:43 UTC

README

Packagist Version Build Status Quality Score

Generate http routing and dispatching middleware from docblock annotations.

Inroute is a code generator. It scans your source tree for annotated routes and generates a PSR-15 compliant http routing middleware. In addition all routes have a middleware pipeline of their own, making it easy to add behaviour at compile time based on custom annotations.

  • See the example-app for a complete example.
  • See console for a compiler tool for the command line.

Installation

composer require inroutephp/inroute

Table of contents

  1. Writing routes
  2. Piping a route through a middleware
  3. Compiling
  4. Dispatching
  5. Generating route paths
  6. Creating custom annotations
  7. Processing routes using compiler passes
  8. Handling dependencies with a DI container
  9. Dealing with routing errors

Writing routes

Routes are annotated using annotations, are called with a PSR-7 request object and inroute environment and are expected to return a PSR-7 response.

use inroutephp\inroute\Annotations\BasePath;
use inroutephp\inroute\Annotations\GET;
use inroutephp\inroute\Runtime\EnvironmentInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\TextResponse;

/**
 * @BasePath(path="/users")
 */
class UserController
{
    /**
     * @GET(
     *     path="/{name}",
     *     name="getUser",
     *     attributes={
     *         "key": "value",
     *         "name": "overwritten by path value"
     *     }
     * )
     */
    function getUser(
        ServerRequestInterface $request,
        EnvironmentInterface $environment
    ): ResponseInterface {
        return new TextResponse(
            // the name attribute from the request path
            $request->getAttribute('name')

            // the custom route attribute
            . $request->getAttribute('key')
        );
    }
}
  • The method and path values are self explanatory.
  • A route name is optional, and defaults to class:method (in the example UserController:getUser).
  • Attributes are custom values that can be accessed at runtime through the request object.
  • Note that the use of Laminas diactoros as a psr-7 response implementation is used in this example, you may of course use another psr-7 implementation.

Piping a route through a middleware

Each route has a PSR-15 middleware pipeline of its own. Adding a middleware to a route can be done using the @Pipe annotation. In the following example the pipedAction route is piped through the AppendingMiddleware and the text ::Middleware is appended to the route response.

use inroutephp\inroute\Annotations\Pipe;

class PipedController
{
    /**
     * @GET(path="/piped")
     * @Pipe(middlewares={"AppendingMiddleware"})
     */
    function pipedAction(
        ServerRequestInterface $request,
        EnvironmentInterface $environment
    ): ResponseInterface {
        return new TextResponse('Controller');
    }
}

use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class AppendingMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface {
        $response = $handler->handle($request);

        return new TextResponse(
            $response->getBody()->getContents() . "::Middleware"
        );
    }
}

Compiling

The recommended way of building a project is by using the console build tool. Compiling from pure php involves setting up the compiler something like the following.

use inroutephp\inroute\Compiler\CompilerFacade;
use inroutephp\inroute\Compiler\Settings\ArraySettings;

$settings = new ArraySettings([
    'source-classes' => [
        UserController::CLASS,
        PipedController::CLASS,
    ],
    'target-namespace' => 'example',
    'target-classname' => 'HttpRouter',
]);

$facade = new CompilerFacade;

$code = $facade->compileProject($settings);

eval($code);

$router = new example\HttpRouter;

Possible settings include

  • container: The classname of a compile time container, specify if needed.
  • bootstrap: Classname of compile bootstrap, default should normally be fine.
  • source-dir: Directory to scan for annotated routes.
  • source-prefix: psr-4 namespace prefix to use when scanning directory.
  • source-classes: Array of source classnames, use instead of or togheter with directory scanning.
  • ignore-annotations: Array of annotations to ignore during compilation
  • route-factory: Classname of route factory, default should normally be fine.
  • compiler: Classname of compiler to use, default should normally be fine.
  • core-compiler-passes: Array of core compiler passes, default should normally be fine.
  • compiler-passes: Array of custom compiler passes.
  • code-generator: The code generator to use, default should normally be fine.
  • target-namespace: The namespace of the generated router (defaults to no namespace).
  • target-classname: The classname of the generated router (defaults to HttpRouter).

OpenApi

Please note that reading openapi annotations is still very rudimentary. Please open an issue if you have suggestions on more values that should be parsed.

Instead of using the built in annotations inroute is also able to build openapi projects annotated with swagger-php annotations.

Set the core-compiler-passes setting to ['inroutephp\inroute\OpenApi\OpenApiCompilerPass'].

Dispatching

The generated router is a PSR-15 compliant middleware. To dispatch you need to supply an implementation of PSR-7 for request and response objects and some response emitting functionality (of course you should also use a complete middleware pipeline for maximum power).

In this simple example we use

use inroutephp\inroute\Runtime\Middleware\Pipeline;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;

// create a simple middleware pipeline for the entire application
$pipeline = new Pipeline($router);

// create a psr-7 compliant response emitter
$emitter = new SapiEmitter;

// fakeing a GET request
$request = (new ServerRequestFactory)->createServerRequest('GET', '/users/foo');

// in the real worl you would of course use
// $request = ServerRequestFactory::fromGlobals();

// create the response
$response = $pipeline->handle($request);

// send it
$emitter->emit($response);

Or to send to piped example from above

use inroutephp\inroute\Runtime\Middleware\Pipeline;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;

(new SapiEmitter)->emit(
    (new Pipeline($router))->handle(
        (new ServerRequestFactory)->createServerRequest('GET', '/piped')
    )
);

Generating route paths

function getUser(ServerRequestInterface $request, EnvironmentInterface $environment): ResponseInterface
{
    return new TextResponse(
        $environment->getUrlGenerator()->generateUrl('getUser', ['name' => 'myUserName'])
    );
}

Creating custom annotations

Inroute uses doctrine to read annotations. Creating custom annotations is as easy as

namespace MyNamespace;

/**
 * @Annotation
 */
class MyAnnotation
{
    public $value;
}

To create annotations that automatically pipes a route through a middleware use something like the following.

namespace MyNamespace;

use inroutephp\inroute\Annotations\Pipe;

/**
 * @Annotation
 */
class AdminRequired extends Pipe
{
    public $middlewares = ['AuthMiddleware', 'RequireUserGroupMiddleware'];
    public $attributes = ['required_user_group' => 'admin'];
}

Note that you need to supply the AuthMiddleware to authenticate a user and the RequireUserGroupMiddleware to check user priviliges for this example to function as expected. See below on how to inject a dependency container that can deliver these middlewares.

And to annotate your controller methods

use MyNamespace\MyAnnotation;
use MyNamespace\AdminRequired;

class MyController
{
    /**
     * @MyAnnotation(value="foobar")
     * @AdminRequired
     */
    public function aRouteThatIsOnlyOpenToAdminUsers()
    {
    }
}

Processing routes using compiler passes

Custom annotations are most useful pared with custom compiler passes.

use inroutephp\inroute\Compiler\CompilerPassInterface;
use inroutephp\inroute\Runtime\RouteInterface;
use MyNamespace\MyAnnotation;

class MyCompilerPass implements CompilerPassInterface
{
    public function processRoute(RouteInterface $route): RouteInterface
    {
        if ($route->hasAnnotation(MyAnnotation::CLASS)) {
            return $route
                ->withAttribute('cool-attribute', $route->getAnnotation(MyAnnotation::CLASS)->value)
                ->withMiddleware(SomeCoolMiddleware::CLASS);
        }

        return $route;
    }
}

Each route has a middleware pipeline of its own. In the example above all routes annotated with MyAnnotation will be wrapped in SomeCoolMiddleware. This makes it easy to add custom behaviour to routes at compile time based on annotations.

The attribute cool-attribute can be accessed in middlewares using $request->getAttribute('cool-attribute').

Handling dependencies with a DI container

You may have noted that in the example above SomeCoolMiddleware was passed not as an instantiated object but as a class name. The actual object is created at runtime using a PSR-11 compliant dependency injection container. The same is true for controller classes.

Create you container as part of your dispatching logic and pass it to the router using the setContainer() method.

$container = /* your custom setup */;

$router = new example\HttpRouter;

$router->setContainer($container);

// continue dispatch...

Dealing with routing errors

Route note found (http code 404) and method not allowed (405) situations can be handled in one of two ways.

Using a ResponseFactoryInterface

If you container contains a service Psr\Http\Message\ResponseFactoryInterface then that factory will be used to create and return a 404 or 405 http response.

Catching exceptions

If no factory is defined a inroutephp\inroute\Runtime\Exception\RouteNotFoundException or inroutephp\inroute\Runtime\Exception\MethodNotAllowedException will be thrown.