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.

src/Util/Logging/LoggingInterceptor.php
use Assegai\Core\ExecutionContext;
use Assegai\Core\Attributes\Injectable;
use Assegai\Core\Interceptors\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.

src/Spears/SpearsController.php
#[UseInterceptors(LoggingInterceptor::class)]
class SpearsController
{
}
Tip The #[UseInterceptors] attribute is imported from the assegaiphp/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:

src/Spears/SpearsController.php
#[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:

src/Spears/SpearsController.php
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:

src/AppModule.php
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.

src/Util/Interceptors/EmptyResultInterceptor.php
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.