tobento/app-http

App http support.


README

Http, routing, middleware and session support for the app.

Table of Contents

Getting Started

Add the latest version of the app http project running this command.

composer require tobento/app-http

Requirements

  • PHP 8.0 or greater

Documentation

App

Check out the App Skeleton if you are using the skeleton.

You may also check out the App to learn more about the app in general.

Http Boot

The http boot does the following:

  • PSR-7 implementations
  • PSR-17 implementations
  • installs and loads http config file
  • base and current uri implementation
  • http error handling implementation
  • emits response
use Tobento\App\AppFactory;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Http::class);

// Run the app
$app->run();

Http Config

Check out app/config/http.php to change needed values.

Request And Response

You may access the PSR-7 and PSR-17 interfaces by the app:

use Tobento\App\AppFactory;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
use Tobento\Service\Uri\BaseUriInterface;
use Tobento\Service\Uri\CurrentUriInterface;
use Tobento\Service\Uri\PreviousUriInterface;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Http::class);
$app->booting();

// PSR-7
$request = $app->get(ServerRequestInterface::class);
$response = $app->get(ResponseInterface::class);

// returns UriInterface
$baseUri = $app->get(BaseUriInterface::class);
$currentUri = $app->get(CurrentUriInterface::class);
$previousUri = $app->get(PreviousUriInterface::class);
// Session Boot is needed, otherwise it is always same as base uri.

// PSR-17
$responseFactory = $app->get(ResponseFactoryInterface::class);
$streamFactory = $app->get(StreamFactoryInterface::class);
$uploadedFileFactory = $app->get(UploadedFileFactoryInterface::class);
$uriFactory = $app->get(UriFactoryInterface::class);

// Run the app
$app->run();

Check out the Uri Service to learn more about the base and current uri.

Swap PSR-7 And PSR-17 Implementation

You might swap the PSR-7 and PSR-17 implementation to any alternative.
Check out the App - Customization to learn more about it.

Requester And Responser Boot

The requester and responser boot does the following:

use Tobento\App\AppFactory;
use Tobento\Service\Requester\RequesterInterface;
use Tobento\Service\Responser\ResponserInterface;

// Create the app
$app = (new AppFactory())->createApp();

// You may add the session boot to enable
// flash messages and flash input data.
$app->boot(\Tobento\App\Http\Boot\Session::class);

$app->boot(\Tobento\App\Http\Boot\RequesterResponser::class);
$app->booting();

$requester = $app->get(RequesterInterface::class);
$responser = $app->get(ResponserInterface);

// Run the app
$app->run();

Check out the Requester Service to learn more about it.

Check out the Responser Service to learn more about it.

Responser Messages

Messages will be translated if you have installed the App Message - Translating Messages.

Added Middleware

Middleware Boot

The middleware boot does the following:

  • PSR-15 HTTP handlers (middleware) implementation
  • dispatches middleware
use Tobento\App\AppFactory;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Middleware::class);
$app->booting();

// add middleware aliases using app macro:
$app->middlewareAliases([
    'alias' => FooMiddleware::class,
]);

// add middleware group using app macro:
$app->middlewareGroup(name: 'api', middlewares: [
    Middleware::class,
]);

// add middleware using app macro:
$app->middleware(BarMiddleware::class);

// Run the app
$app->run();

Check out the Middleware Service to learn more about the middleware implementation.

Add Middleware via Config

You can configure middleware in the config file app/config/middleware.php which are applied to all routes and requests:

return [
    // ...
    'middlewares' => [
        // priority => middleware
        
        // via fully qualified class name:
        8000 => \Tobento\App\Http\Middleware\SecurePolicyHeaders::class,
        
        // with build-in parameters:
        7900 => [AnotherMiddleware::class, 'name' => 'Sam'],
        
        // by alias:
        7800 => 'aliasedMiddleware',
        
        // by group name:
        7800 => 'groupedMiddlewares',
        
        // by class instance:
        7700 => new SomeMiddleware(),
    ],
];

Add Middleware via Boot

You might create a boot for adding middleware:

use Tobento\App\Boot;
use Tobento\App\Http\Boot\Middleware;

class MyMiddlewareBoot extends Boot
{
    public const BOOT = [
        // you may ensure the middleware boot.
        Middleware::class,
    ];
    
    public function boot(Middleware $middleware)
    {
        $middleware->add(MyMiddleware::class);
    }
}

Middleware Aliases

use Tobento\App\Boot;
use Tobento\App\Http\Boot\Middleware;

class MyMiddlewareBoot extends Boot
{
    public const BOOT = [
        // you may ensure the middleware boot.
        Middleware::class,
    ];
    
    public function boot(Middleware $middleware)
    {
        $middleware->addAliases([
            'alias' => MyMiddleware::class,
        ]);
        
        // add by alias:
        $middleware->add('alias');
    }
}

Add Aliases via Config

You can configure middleware aliases in the config file app/config/middleware.php:

return [
    // ...
    'aliases' => [
        'alias' => MyMiddleware::class,
    ],
];

Middleware Groups

use Tobento\App\Boot;
use Tobento\App\Http\Boot\Middleware;

class MyMiddlewareBoot extends Boot
{
    public const BOOT = [
        // you may ensure the middleware boot.
        Middleware::class,
    ];
    
    public function boot(Middleware $middleware)
    {
        $middleware->addGroup(name: 'api', middlewares: [
            Middleware::class,
            // with build-in parameters:
            [AnotherMiddleware::class, 'name' => 'Sam'],
            // by alias:
            'aliasedMiddleware',
            // by class instance:
            new SomeMiddleware(),
        ]);
        
        // add by group:
        $middleware->add('api');
    }
}

Add Groups via Config

You can configure middleware groups in the config file app/config/middleware.php:

return [
    // ...
    'groups' => [
        'name' => [
            SomeMiddleware::class,
        ],
    ],
];

Available Middleware

Previous Uri Session Middleware

The Tobento\App\Http\Middleware\PreviousUriSession::class middleware is automatically added by the Session Boot which stores the uri history in the session.

Get Previous Uri

$previousUri = $app->get(PreviousUriInterface::class);

Exclude From Previous Uri History

You may exclude a certain uri from the history by adding a X-Exclude-Previous-Uri header on the response:

$response = $response->withHeader('X-Exclude-Previous-Url', '1');

Secure Policy Headers Middleware

This middleware will add the following secure policy headers to the response:

  • Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: same-origin
  • Content-Security-Policy: base-uri 'none'; default-src 'self'; img-src 'self' data:; script-src 'nonce-***' 'self'; object-src 'none'; style-src 'nonce-***' 'self';

In the app/config/middleware.php file:

'middlewares' => [
    8000 => \Tobento\App\Http\Middleware\SecurePolicyHeaders::class,
],

Using inline scripts and styles

The middleware will add a Content-Security-Policy header with a nonce for script-src and style-src. Therefore, to allow inline scripts or styles you must add the nonce to the html:

If using the App View bundle, you can retrieve the nonce from the view:

<style nonce="<?= $view->esc($view->get('cspNonce', '')) ?>">
    ...
</style>

<script nonce="<?= $view->esc($view->get('cspNonce', '')) ?>">
    ...
</script>

Routing Boot

The routing boot does the following:

  • boots http and middleware boot
  • RouterInterface implementation
  • adds routing macro
  • adds http error handler for routing exceptions
use Tobento\App\AppFactory;
use Tobento\Service\Routing\RouterInterface;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Routing::class);
$app->booting();

// using interface:
$router = $app->get(RouterInterface::class);
$router->get('blog', function() {
    return ['page' => 'blog'];
});

// using macros:
$app->route('GET', 'foo', function() {
    return ['page' => 'foo'];
});

// Run the app
$app->run();

Check out the Routing Service to learn more about the routing.

Routing via Boot

You might create a boot for defining routes:

use Tobento\App\Boot;
use Tobento\Service\Routing\RouterInterface;
use Tobento\Service\Routing\RouteGroupInterface;

class RoutesBoot extends Boot
{
    public const BOOT = [
        // you may ensure the routing boot.
        \Tobento\App\Http\Boot\Routing::class,
    ];
    
    public function boot(RouterInterface $router)
    {
        // Add routes on the router
        $router->get('blog', [Controller::class, 'method']);
        
        // Add routes with the provided app macros
        $this->app->route('GET', 'blog', [Controller::class, 'method']);
        
        $this->app->routeGroup('admin', function(RouteGroupInterface $group) {

            $group->get('blog/{id}', function($id) {
                // do something
            });
        });
        
        $this->app->routeResource('products', ProductsController::class);
        
        $this->app->routeMatched('blog.edit', function() {
            // do something after the route has been matched.
        });
        
        $url = $this->app->routeUrl('blog.edit', ['id' => 5])->get();
    }
}

Then adding your routes boot on the app:

use Tobento\App\AppFactory;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(RoutesBoot::class);

// Run the app
$app->run();

Domain Routing

You may specify the domains for routing in the app/config/http.php file.

Check out the Routing Service - Domain Routing section to learn more about domain routing.

Route Handler

You may add route handlers to interact with the route handling.

Here is some benefits of adding handlers:

  • you may cache responses
  • on certain requests you may throw an exception to be later catched by an error handler (e.g. validation)
  • and much more

First, create a route handler:

use Tobento\App\AppInterface;
use Tobento\Service\Routing\RouteInterface;
use Tobento\Service\Routing\RouteHandlerInterface;
use Tobento\App\Http\Routing\DeclaredHandlerParameters;
use Tobento\App\Http\Routing\ArgumentsHandlerParameters;
use Psr\Http\Message\ServerRequestInterface;

final class CustomRouteHandler implements RouteHandlerInterface
{
    public function __construct(
        private AppInterface $app
    ) {}
    
    /**
     * Handles the route.
     *
     * @param RouteInterface $route
     * @param null|ServerRequestInterface $request
     * @return mixed The return value of the handler called.
     */
    public function handle(RouteInterface $route, null|ServerRequestInterface $request = null): mixed
    {
        // You may interfere with the arguments for the route handler to be called:
        $arguments = $route->getParameter('_arguments');
        
        var_dump($arguments instanceof ArgumentsHandlerParameters);
        // bool(true)
        
        // You may use the declared route handler parameters:
        $declared = $route->getParameter('_declared');
        
        var_dump($declared instanceof DeclaredHandlerParameters);
        // bool(true)
        
        // Let further handlers handle it:
        return [$route, $request];
        
        // Or prevent further handlers from being called
        // and returning the route handler result:
        $result = $this->app->call($route->getHandler(), $arguments->getParameters());
        
        return [$route, $request, $result];
    }
}

Finally, add the route handler:

use Tobento\App\Http\Routing\RouteHandlerInterface;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots:
$app->boot(\Tobento\App\Http\Boot\Routing::class);

// Add route handler using the app on method:
$app->on(RouteHandlerInterface::class, function(RouteHandlerInterface $handler) {
    $handler->addHandler(CustomRouteHandler::class);
})->priority(1500);

// Run the app
$app->run();

The only handler added is the Tobento\App\Http\Routing\RequestRouteHandler::class with a priority of 1000.

Route List Command

If you have installed the App Console you may run the route:list command providing an overview of all the routes that are defined by your application:

php ap route:list

Displays only specific routes by its name with additional information:

php ap route:list --name=blog.show

Session Boot

The session boot does the following:

use Tobento\App\AppFactory;
use Tobento\Service\Session\SessionInterface;
use Psr\Http\Message\ServerRequestInterface;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Middleware::class);
$app->boot(\Tobento\App\Http\Boot\Routing::class);
$app->boot(\Tobento\App\Http\Boot\Session::class);
$app->booting();

$app->route('GET', 'foo', function(SessionInterface $session) {
    $session->set('key', 'value');
    return ['page' => 'foo'];
});

// or you may get the session from the request attributes:
$app->route('GET', 'bar', function(ServerRequestInterface $request) {
    $session = $request->getAttribute(SessionInterface::class);
    $session->set('key', 'value');
    return ['page' => 'bar'];
});

// Run the app
$app->run();

Check out the Session Service to learn more about the session in general.

Session Config

Check out app/config/session.php to change needed values.

Session Lifecycle

The session gets started and saved by the session middleware whereby interacting with session data is available after.

Session Error Handling

You may add an error handler for handling exceptions caused by the session middleware.

use Tobento\App\Boot;
use Tobento\App\Http\HttpErrorHandlersInterface;
use Tobento\Service\Session\SessionStartException;
use Tobento\Service\Session\SessionExpiredException;
use Tobento\Service\Session\SessionValidationException;
use Tobento\Service\Session\SessionSaveException;
use Throwable;

class HttpErrorHandlerBoot extends Boot
{
    public const BOOT = [
        // you may ensure the http boot.
        \Tobento\App\Http\Boot\Http::class,
    ];
    
    public function boot()
    {
        $this->app->on(HttpErrorHandlersInterface::class, function(HttpErrorHandlersInterface $handlers) {

            $handlers->add(function(Throwable $t) {
                
                if ($t instanceof SessionStartException) {
                    // You may do something if starting session fails.
                } elseif ($t instanceof SessionExpiredException) {
                    // This is already handled by the session middleware,
                    // so you might check it out.
                } elseif ($t instanceof SessionValidationException) {
                    // You may do something if session validation fails. 
                } elseif ($t instanceof SessionSaveException) {
                    // You may do something if saving session fails. 
                }
                
                return $t;
            })->priority(2000); // you might add a priority.
        });
    }
}

You may handle these exceptions with the Error Handler - Handle Other Exceptions instead.

Check out the Throwable Handlers to learn more about handlers in general.

Cookies Boot

The cookies boot does the following:

  • implements cookie interfaces based on cookies config file
  • adds middleware based on cookies config file
use Tobento\App\AppFactory;
use Tobento\Service\Cookie\CookiesFactoryInterface;
use Tobento\Service\Cookie\CookieFactoryInterface;
use Tobento\Service\Cookie\CookieValuesFactoryInterface;
use Tobento\Service\Cookie\CookiesProcessorInterface;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Cookies::class);
$app->booting();

// The following interfaces are available after booting:
$cookiesFactory = $app->get(CookiesFactoryInterface::class);
$cookieFactory = $app->get(CookieFactoryInterface::class);
$cookieValuesFactory = $app->get(CookieValuesFactoryInterface::class);
$cookiesProcessor = $app->get(CookiesProcessorInterface::class);

// Run the app
$app->run();

Check out the Cookie Service to learn more it.

Cookies Config

Check out app/config/cookies.php to change needed values.

Cookies Usage

Read and write cookies

use Tobento\App\AppFactory;
use Tobento\Service\Cookie\CookieValuesInterface;
use Tobento\Service\Cookie\CookiesInterface;
use Psr\Http\Message\ServerRequestInterface;

// Create the app
$app = (new AppFactory())->createApp();

// Adding boots
$app->boot(\Tobento\App\Http\Boot\Routing::class);
$app->boot(\Tobento\App\Http\Boot\Cookies::class);
$app->booting();

$app->route('GET', 'bar', function(ServerRequestInterface $request) {

    // read cookies:
    $cookieValues = $request->getAttribute(CookieValuesInterface::class);
    
    $value = $cookieValues->get('foo');
    
    // or
    var_dump($request->getCookieParams());
    
    // write cookies:
    $cookies = $request->getAttribute(CookiesInterface::class);
    
    $cookies->add('name', 'value');
    
    return ['page' => 'bar'];
});

// Run the app
$app->run();

Check out the Cookie Values to learn more it.

Check out the Cookies to learn more it.

Cookies Encryption

First install the app-encryption bundle:

composer require tobento/app-encryption

Then, just boot the Encryption::class if you want to encrypt and decrypt all cookies values. That's all.

// ...

$app->boot(\Tobento\App\Encryption\Boot\Encryption::class);
$app->boot(\Tobento\App\Http\Boot\Cookies::class);

// ...

Whitelist cookie

To whitelist a cookie (disable encryption), use the CookiesProcessorInterface::class after the booting:

use Tobento\Service\Cookie\CookiesProcessorInterface;

$cookiesProcessor = $app->get(CookiesProcessorInterface::class);

$cookiesProcessor->whitelistCookie(name: 'name');

// or
$cookiesProcessor->whitelistCookie(name: 'name[foo]');
$cookiesProcessor->whitelistCookie(name: 'name[bar]');

Configuration

The encrypting and decrypting is done with the implemented CookiesProcessor::class processed by the specified middleware in the app/config/cookies.php file.

use Tobento\Service\Cookie;
use Tobento\Service\Encryption;
use Psr\Container\ContainerInterface;

return [

    'middlewares' => [
        Cookie\Middleware\Cookies::class,
    ],

    'interfaces' => [

        //...

        Cookie\CookiesProcessorInterface::class => Cookie\CookiesProcessor::class,

        // or you may use a specified encrypter only for cookies:
        Cookie\CookiesProcessorInterface::class => static function(ContainerInterface $c): Cookie\CookiesProcessorInterface {

            $encrypter = null;

            if (
                $c->has(Encryption\EncryptersInterface::class)
                && $c->get(Encryption\EncryptersInterface::class)->has('cookies')
            ) {
                $encrypter = $c->get(Encryption\EncryptersInterface::class)->get('cookies');
            }

            return new Cookie\CookiesProcessor(
                encrypter: $encrypter,
                whitelistedCookies: [],
            );
        },
    ],

    //...
];

You may check out the App Encryption to learn more about it.

Error Handler Boot

By default, the error handler will render exceptions in json or plain text format. If you want to render exception views to support html and xml formats check out the Render Exception Views section.

// ...
$app->boot(\Tobento\App\Http\Boot\ErrorHandler::class);
// ...

It handles the following exceptions as well as the Http Exceptions:

Http Exceptions

There are several HTTP exceptions you can throw from your controllers and middleware, which are handled by the default error handler:

Example:

use Tobento\App\Http\Exception\HttpException;
use Tobento\App\Http\Exception\NotFoundException;

class SomeController
{
    public function index()
    {
        throw new HttpException(statusCode: 404);
        
        // or:
        throw new NotFoundException();
    }
}

Render Exception Views

In order to render exceptions in html or xml format, the ViewInterface::class must be available within the app. You might install the App View bundle or just implement the ViewInterface::class:

composer require tobento/service-view
use Tobento\Service\View;
use Tobento\Service\Dir\Dirs;
use Tobento\Service\Dir\Dir;

// ...
$app->set(View\ViewInterface::class, function() {
    return new View\View(
        new View\PhpRenderer(
            new Dirs(
                new Dir('home/private/views/'),
            )
        ),
        new View\Data(),
        new View\Assets('home/public/src/', 'https://www.example.com/src/')
    );
});
// ...

It renders the following view if exist:

Handle Other Exceptions

You might handle other exceptions by just exending the error handler:

Messages will be translated if you have installed the App Translation using uses the * as resource name. Check out the Translation Resources and Translation Files Resources to learn more about it.

use Tobento\App\Http\Boot\ErrorHandler;
use Tobento\Service\Requester\RequesterInterface;
use Tobento\Service\Responser\ResponserInterface;
use Psr\Http\Message\ResponseInterface;
use Throwable;

class CustomErrorHandler extends ErrorHandler
{
    public function handleThrowable(Throwable $t): Throwable|ResponseInterface
    {
        $requester = $this->app->get(RequesterInterface::class);
        
        if ($t instanceof SomeException) {
            return $requester->wantsJson()
                ? $this->renderJson(code: 404)
                : $this->renderView(code: 404);
            
            // or with custom message:
            return $requester->wantsJson()
                ? $this->renderJson(code: 404, message: 'Custom')
                : $this->renderView(code: 404, message: 'Custom');
                
            // or with custom message and message parameters:
            return $requester->wantsJson()
                ? $this->renderJson(code: 404, message: 'Custom :value', parameters: [':value' => 'foo'])
                : $this->renderView(code: 404, message: 'Custom :value', parameters: [':value' => 'foo']);   
        }
        
        // using the responser:
        if ($t instanceof SomeOtherException) {
            $responser = $this->app->get(ResponserInterface::class);
            
            return $responser->json(
                data: ['key' => 'value'],
                code: 200,
            );
        }        
        
        return parent::handleThrowable($t);
    }
}

And boot your custom error handler instead of the default:

// ...
$app->boot(CustomErrorHandler::class);
// ...

Prioritize Error Handler

You may create an error handler and use the HANDLER_PRIORITY constant to define a priority.

The default priority is 1500, higher gets handled first.

use Tobento\App\Http\Boot\ErrorHandler;
use Tobento\Service\Requester\RequesterInterface;
use Tobento\Service\Responser\ResponserInterface;
use Psr\Http\Message\ResponseInterface;
use Throwable;

class PrioritizedErrorHandler extends ErrorHandler
{
    protected const HANDLER_PRIORITY = 5000;
    
    public function handleThrowable(Throwable $t): Throwable|ResponseInterface
    {
        $requester = $this->app->get(RequesterInterface::class);
        
        if ($t instanceof SomeException) {
            return $requester->wantsJson()
                ? $this->renderJson(code: 404)
                : $this->renderView(code: 404);
        }
        
        // using the responser:
        if ($t instanceof SomeOtherException) {
            $responser = $this->app->get(ResponserInterface::class);
            
            return $responser->json(
                data: ['key' => 'value'],
                code: 200,
            );
        }        
        
        // return throwable to let other handler handle it:
        return $t;
    }
}

And boot your error handler:

// ...
$app->boot(PrioritizedErrorHandler::class);

$app->boot(DefaultErrorHandler::class);

// you could boot it after the default,
// it gets called first if priority is higher as default:
$app->boot(PrioritizedErrorHandler::class);
// ...

Credits