Creating a one endpoint api with PHP and Symfony (Part 3)

Nacho Colomina
4 min readDec 15, 2022

--

In part 1 and 2 of this article we saw:

  • The basics to build a one api endpoint which can discover the operation to perform from a parameter in the payload
  • How to protect our api with an API token and how to protect operation access with symfony voters

In this part, we’re showing how to perform api operations in the background. This can be useful when an operation can take much time to finish, for instance an operation which connects to an external service or perfoms complex calculations.

If you have not read the other parts of this article, you can do it in the following links:

Part 1: https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-d5f03d3141c0

Part 2: https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-part-2-187604ffeb67

Creating an attribute to mark an operation to execute in the background

In order to mark an operation to execute in the background, we’re using PHP 8 attributes. An attribute allow programmers to add metadata to classes, methods and properties.

You can read more about attributes here https://www.php.net/manual/en/language.attributes.overview.php

Let’s see how our attribute looks like:

#[\Attribute]
class IsBackground
{
public ?int $delay;

public function __construct(?int $delay = null)
{
$this->delay = $delay;
}

}

The created attribute has an optional parameter, delay, which allows us to perform the operation after the specified seconds. If we don’t specify any delay, operation will be performed as soon as possible.

Now, we have to return to ApiOperationHandler and, before performing operation, check if the operation has to be sent to the background and, if so, send it

Before modifying ApiOperationHandler, let’s install symfony messenger so we can create the message and handler to send operations to the background

composer install symfony/messenger

You can learn more about symfony messenger on symfony docs: https://symfony.com/doc/current/messenger.html

We already have symfony messenger installed so let’s see how message and handler looks like:

class ApiOperationMessage
{
private ApiInput $apiInput;

public function __construct(ApiInput $apiInput)
{
$this->apiInput = $apiInput;
}

public function getApiInput(): ApiInput
{
return $this->apiInput;
}

}
#[AsMessageHandler]
class ApiOperationMessageHandler
{

private ApiOperationsCollection $apiOperationsCollection;

public function __construct(ApiOperationsCollection $apiOperationsCollection)
{
$this->apiOperationsCollection = $apiOperationsCollection;
}


public function __invoke(ApiOperationMessage $message): void
{
$apiInput = $message->getApiInput();
$this->apiOperationsCollection->getOperation($apiInput->getOperation())->perform($apiInput);
}
}

As we can see, the message takes an ApiInput as an argument. Message handler takes the ApiInput argument from the message and, as ApiOperationHandler does, gets the operation from the collection and performs it. In this case, message handler can avoid checking whether operation exists since ApiOperationHandler does it.

Now, let’s modify ApiOperationHandler as we said before:

class ApiOperationHandler
{
private ApiOperationsCollection $apiOperationsCollection;

private Security $security;
private MessageBusInterface $bus;

public function __construct(ApiOperationsCollection $apiOperationsCollection, Security $security, MessageBusInterface $bus)
{
$this->apiOperationsCollection = $apiOperationsCollection;
$this->security = $security;
$this->bus = $bus;
}

/**
* @throws \ReflectionException
*/
public function performOperation(ApiInput $apiInput): ApiOutput
{
if(!$this->apiOperationsCollection->hasOperation($apiInput->getOperation())){
throw new ApiOperationException([], sprintf('Operation %s is not defined', $apiInput->getOperation()));
}

$operationHandler = $this->apiOperationsCollection->getOperation($apiInput->getOperation());
if(!$this->security->isGranted(
'EXECUTE_OPERATION',
new ApiOperationSubject(get_class($operationHandler), $operationHandler->getGroup()))
){
throw new AccessDeniedException('Not allowed to perform this operation');
}

$attribute = $this->getBackgroundAttr($operationHandler);
if($attribute){
$stamps = ($attribute->delay > 0) ? [new DelayStamp($attribute->delay * 1000)] : [];
$this->bus->dispatch(new ApiOperationMessage($apiInput), $stamps);
return new ApiOutput(
['status' => 'Queued'],
Response::HTTP_ACCEPTED
);
}



return $operationHandler->perform($apiInput);
}

private function getBackgroundAttr(ApiOperationInterface $operation): ?IsBackground
{
$reflectionClass = new \ReflectionClass($operation);
$attributes = $reflectionClass->getAttributes(IsBackground::class);
if(count($attributes) > 0) {
$attr = reset($attributes);
return $attr->newInstance();
}

return null;
}
}

Now, ApiOperationHandler looks for the IsBackground attribute into the operation. Is attribute exists, then the operation is sent to the background using dispatch method of MessageBusInterface service and an ApiOutput object is returned with a 202 Accepted Http code. Otherwise operation will be performed in real time.

As said, message handler does not need to check whether operation exists since ApiOperationHandler checks for operation and permissions before checking background.

Checking operation is sent to the background

To check it works, let’s annotate our ListCountriesOperation with the isBackground attribute.

#[IsBackground]
class ListCountriesOperation implements ApiOperationInterface

Now, let’s use postman to see what happens when sending a request for the operation

Request sent to an operation with background attribute

As we can see, operation execution has been sent to the background and a 202 Accepted http code has been returned

This is only an example, there wouldn’t have no sense to send to the background an operation which has to return this kind of list

If we would want to delay the execution, for instance, 1 hour, we will have to add a delay to the attribute in seconds:

#[IsBackground(delay: 86400)]
class ListCountriesOperation implements ApiOperationInterface

If delay is provided, ApiOperationHandler use a DelayStamp to delay the message

In order to really sent the message to the background with symfony messenger, you have to configure a transport and route the message to that transport. Symfony messeger supports several transport backends such as AMPQ Brokers, Redis and Doctrine. Check this on the docs link https://symfony.com/doc/current/messenger.html#transports-async-queued-messages

Last words

We’ve seen how to send an operation to the background so that our api can process an operation which takes long time to perform without failing by timeout. We have to take into account that this way can be useful only when all the operation logic has to be executed in the background. On the other hand, there are operations which only have to send to the background part of his task, like an operation which registers a user and sends a confirmation email. In this case, only sending the email should be executed in the background and we would have to achieve this following another way, for instance dispatching an event which would send the sending email process to the background.

--

--