Interceptors
An interceptor is a class in Assegai that is marked with the #[Injectable]
attribute and
implements the IAssegaiInterceptor
interface. These classes are inspired by Aspect Oriented
Programming (AOP) and provide several useful capabilities, such as:
- Adding extra logic before and after the execution of a method
- Changing the result returned from a function
- Modifying the exception thrown by a function
- Enhancing the basic behavior of a function
- Overriding a function under certain conditions (e.g. for caching purposes)
Basics
The intercept()
method in an interceptor class takes an ExecutionContext
instance
as its first argument, which is the same object used for guards. ExecutionContext
extends
ArgumentsHost
and implements ExecutionContext
, which acts as a wrapper for the
arguments passed to the original handler and contains various argument arrays based on the type of
application. The ExecutionContext
also provides additional details about the current execution
process, which can be useful for building more generic interceptors that work across a wide range of
controllers, methods, and execution contexts.
Execution context
ExecutionContext
is a class that provides additional information about the current execution
process. It extends ArgumentsHost
, which is a wrapper around arguments passed to the original
handler, and contains different argument arrays depending on the type of the application.
ExecutionContext
adds new helper methods that can be used to build more generic interceptors
that can be used across a range of controllers, methods, and execution contexts. More information about
ExecutionContext
can be found in the provided documentation.
Callable responses
The intercept()
method may return a callable
object which allows for custom logic
to be implemented before and after the execution of the final route handler. When the intercept()
method returns a callable
, it wraps the request/response stream and the callable's logic can
further manipulate the response. This technique, called a Pointcut, allows for the insertion of additional
logic at a specific point in the process. For example, if an interceptor that returns a callable
is used for an incoming POST /spears
request destined for the create()
handler in the
SpearsController
, the create()
method will be executed and any logic in the
callable
will be executed before a response is sent to the client.
Aspect interception
Here is an example of using the LoggingInterceptor
to log user interaction. This interceptor
logs messages before and after method execution. The interceptor can be injected with dependencies through
its constructor like other Assegai components such as controllers, providers, and guards.
use Assegai\Core\ExecutionContext; use Assegai\Core\Attributes\Injectable; use Assegai\Core\Interfaces\IAssegaiInterceptor; use Assegai\Core\Util\Debug\Log; #[Injectable] class LoggingInterceptor implements IAssegaiInterceptor { public function intercept(ExecutionContext $context): ?callable { Log::info('Before...'); return function() { Log::info('After...'); }; } }
Binding interceptors
To set up the interceptor, you can use the #[UseInterceptors]
attribute. This attribute can be
used to specify the scope of the interceptor, which can be controller-scoped, method-scoped, or global-scoped.
Like pipes and guards, the interceptor's scope can be specified with the #[UseInterceptors]
attribute.
#[UseInterceptors(LoggingInterceptor::class)] class SpearsController { }
Tip The#[UseInterceptors]
attribute is imported from theassegaiphp/core
package.
Using the above construction, each route handler defined in SpearsController
will use
LoggingInterceptor
. When someone calls the GET /spears
endpoint, you'll see the
following output in your standard output:
To set up an interceptor, you can use the #[UseInterceptors]
attribute. This attribute allows
you to specify which interceptor to use, and where to use it. The interceptor can be applied to the entire
controller, a specific method within the controller, or globally across the entire application. You can pass
the interceptor as a type (e.g., LoggingInterceptor
) or as an instance
(e.g., new LoggingInterceptor()
).
For example, to apply the LoggingInterceptor
to every handler in the SpearsController
,
you can use the following code:
#[UseInterceptors(new LoggingInterceptor())] class SpearsController { }
If you want to apply the interceptor to a specific method, you can use the attribute at the method level, like this:
class SpearsController { #[UseInterceptors(LoggingInterceptor)] public function create(): void { // method logic here } }
When the interceptor is called, it will log a message to the standard output before and after the method is
executed. For example, if the GET /spears
endpoint is called, you will see the following output:
Before... After...
Keep in mind that interceptors, like other Assegai components, can have dependencies injected through their constructors.
Global Interceptors
To set up a global interceptor that will be used across the entire application, you can use the
useGlobalInterceptors()
method of the Assegai application instance:
$app = AssegaiFactory::create(AppModule::class); $app->useGlobalInterceptors(new LoggingInterceptor());
Global interceptors apply to all controllers and route handlers within the Assegai application. However,
when a global interceptor is registered from outside a module using the useGlobalInterceptors()
method, it cannot utilize dependency injection because it is not being registered within the context of a
module. To enable dependency injection for a global interceptor, it can be set up directly from within a
module using the following code snippet:
use Assegai\Core\Attributes\Module; #[Module(providers: [LoggingInterceptor::class])] class AppModule {}
Response mapping
Interceptors allow you to create reusable solutions to common problems across an application. For instance, you can use an interceptor to change the response code whenever an empty result is returned. You can bind this interceptor globally, so that it will be applied to all registered handlers.
use Assegai\Core\ExecutionContext; use Assegai\Core\Interfaces\IAssegaiInterceptor; class EmptyResultInterceptor implements IAssegaiInterceptor { public function __construct(public readonly int $code = 404) { } public function intercept(ExecutionContext $context): ?callable { $code = $this->code; return function (ExecutionContext $context) use ($code) { $response = $context->switchToHttp()->getResponse(); if (empty($response->getBody())) { $response->setStatus($code); } return $context; }; } }
Exception mapping
An interceptor can be used to override thrown exceptions and perform custom actions based on the status of
the exception. The following example shows an interceptor that checks the status of an exception and performs
a specific action if the status is 404
(Not Found) or if it matches the HTTP status code for
Not Found
:
use Assegai\Core\ExecutionContext; use Assegai\Core\Interfaces\IAssegaiInterceptor; #[Injectable] class ErrorsInterceptor implements IAssegaiInterceptor { public function intercept(ExecutionContext $context): ?callable { return function(ExecutionContext $context) { $status = $context->switchHttp()->getResponse()->getStatus(); if ($status === 404 || $status === HttpStatus::NotFound()) { // Perform custom action here } return $context; }; } }
This interceptor checks the status of the exception and, if it is 404
or matches the HTTP status
code for Not Found
, performs a custom action. The action could be anything from logging the
error to displaying a custom error message to the user.
Stream overriding
The following example demonstrates how to create a cache interceptor that returns a stored response from a cache in order to improve response time. In a realistic scenario, it may be necessary to consider other factors such as Time To Live (TTL), cache invalidation, and cache size. However, this example focuses on the main concept of returning a response from a cache to prevent calling the handler.
use Assegai\Core\ExecutionContext; use Assegai\Core\Interfaces\IAssegaiInterceptor; #[Injectable] class CacheInterceptor implements IAssegaiInterceptor { public function intercept(ExecutionContext $context): ?callable { $isCached = true; if ($isCached) { $context->switchToHttp()->getResponse()->setBody([]); return $context; } return null; } }
The CacheInterceptor
has a hardcoded boolean variable, $isCached
, and a hardcoded
response of an empty array. The main point to note is that we return a new stream in the interceptor, which
means the route handler will not be called at all. When an endpoint that uses the CacheInterceptor
is called, the response (an empty array) will be returned immediately instead of calling the route handler.