Symfony api one endpoint. Pushing notifications using mercure

Nacho Colomina
6 min readJan 17, 2023

--

In the article https://medium.com/@ict80/creating-a-one-endpoint-api-with-php-and-symfony-part-3-8955325c5101, I created an attribute to mark api operations to be executed in the background. When an annotated operation was called as background, its execution was delayed using symfony messenger and the client received an HTTP 202 Accepped response.

In this article, I will make some changes to allow operations to notify the user when they finish. To achieve it, I will use mercure since symfony integrates with it really well.

I am going to avoid code from some classes to focus only in the modifications. If you want to see the entire code you can check the other articles:

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

Part 3: https://medium.com/@ict80/creating-a-one-endpoint-api-with-php-and-symfony-part-3-8955325c5101

Part 4: https://medium.com/@ict80/creating-a-one-endpoint-api-with-php-and-symfony-part-4-9cfc0e2f9925

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

Installing mercure

First of all, let’s start installing mercure using composer:

composer require mercure

Symfony provides us a docker mercure hub instalation. After installing it, we can take a look to docker-compose.yaml file where we can see mercure service. To start the hub you only have to execute this on your project root:

docker-compose up -d

This will build and start a container for each service of your docker-compose file

Preparing operation

Now we have our mercure hub running, we have to tell the operations we want to notify user when they finish. To do this, we are going to create the following interface:

interface ApiOperationNotificationInterface
{
public function getNotificationData(): string;
public function getTopic(): string;
}

Operations which implement the above interface will have to implement two methods:

  • getNotificationData(): It will return data to push to mercure.
  • getTopic(): Returns the topic where pushing data.

According to the documentation, topics should follow an Internationalized Resource Identifier format. In our case, I am going to format topics following that way: https://<mydomain>/<userIdenfier>/<subject> where:

  • mydomain: The domain we choose.
  • userIdentifier: User identifier.
  • subject: Which type of operation we notify, for instance payments.

Thanks to userIdentifier, we can separate topics per user so that each user will subscribe to his own topics.

Let’s create an background operation which implements ApiNotificationOperationInterface:

#[IsBackground]
class SendPaymentOperation implements ApiOperationInterface, ApiOperationNotificationInterface
{

private SentPaymentNotification $notification;

public function perform(ApiInput $apiInput): ApiOutput
{

// Some code which process the payment

$this->notification = new SentPaymentNotification('Your payment has been processed successfully', SentPaymentNotification::OK);
return new ApiOutput([], Response::HTTP_OK);
}

public function getName(): string
{
return 'SendPaymentOperation';
}

public function getInput(): string
{
return '';
}

public function getGroup(): ?string
{
return null;
}


public function getNotificationData(): string
{
return $this->notification;
}

public function getTopic(): string
{
return 'https://topics.domain.com/%s/payments';
}
}

The operation use an object of class SentPaymentNotification to store notification data we want to push to the client. Let’s see how it looks:

class SentPaymentNotification
{
const OK = 'OK';
const KO = 'KO';

public function __construct(
private readonly string $message,
private readonly string $status
){ }

public function __toString(): string
{
return json_encode(['message' => $this->message, 'status' => $this->status]);
}
}

As we can see, SendPaymentNotification simply builds a json with params it gets through the constructor into __toString method. Using this way we can give the string representation we want to the class.

Making changes to operation handler and message handler

As topics require the userIdentifier, we will have to pass it to ApiOperationMessage so let’s add a new parameter to its constructor:

class ApiOperationMessage
{
public function __construct(
public readonly ApiInput $apiInput,
public readonly string $userUuid
){ }
}

In this way, ApiOperationMessageHandler have access to user uuid. Let’s modify ApiOperationHandler so we pass user uuid to ApiOperationMessage when dispatching an operation to the background:

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
{
// ....... Rest of the code

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



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

// .......
}

Now, let’s see how the ApiOperationMessageHandler behaves when an operation implements ApiOperationNotificationInterface.

#[AsMessageHandler]
class ApiOperationMessageHandler
{
public function __construct(
private readonly ApiOperationsCollection $apiOperationsCollection,
private readonly LockHandler $lockHandler,
private readonly MessageBusInterface $bus,
private readonly AttributeHelper $attributeHelper,
private readonly HubInterface $hub
){ }


/**
* @throws \RedisException
*/
public function __invoke(ApiOperationMessage $message): void
{

// Rest of the code .......

if($operation instanceof ApiOperationNotificationInterface){

$topic = sprintf($operation->getTopic(), Uuid::fromString($message->userUuid)->toBase32());

$this->hub->publish(
new Update(
$topic,
$operation->getNotificationData()
)
);
}
}
}

At the end of the __invoke method, we check if operation implements ApiOperationNotificationInterface. If so, we generate the topic with user base32 encoded uuid and publish to the hub with data we get from method getNotificationData().

To manage uuids, I’ve installed symfony uid component

Creating a custom user provider

If you remember from https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-part-2-187604ffeb67 we created our user using symfony in memory users since it served us as an example for the objective of the article.

The problem here is that in_memory_users does not allow more fields than identifier and roles so we’ve created a custom user provider to be able to add an uuid to the user. Let’s look it:

class UserProvider implements UserProviderInterface
{

public function refreshUser(UserInterface $user)
{
// TODO: Implement refreshUser() method.
}

public function supportsClass(string $class)
{
return $class === User::class;
}

public function loadUserByIdentifier(string $identifier): UserInterface
{
$users = [
[
'identifier' => "fYDg7nMhlvxAtL7KfBnS",
'roles' => ["ROLE_USER", "ROLE_SUPERUSER"],
'uuid' => 'c5b6206c-340c-41e6-abed-18ca2258c578'
]
];

$users = array_filter($users, fn($u) => $u['identifier'] === $identifier);
if(count($users) == 0){
throw new UserNotFoundException('User does not exists');
}

return new User(array_pop($users));
}
}

This custom user provider simply has an array with the user we had in in_memory_users, and uses array_filter function to search the user with match with the identifier. If no user found a UserNotFoundHttpException is thrown, otherwise returns a User object. Let’s see User object:

class User implements UserInterface
{

private string $uuid;
private array $roles;
private string $identifier;

public function __construct(array $userData)
{
$this->uuid = $userData['uuid'];
$this->roles = $userData['roles'];
$this->identifier = $userData['identifier'];
}


public function getRoles(): array
{
return $this->roles;
}

public function eraseCredentials()
{

}

public function getUserIdentifier(): string
{
return $this->identifier;
}

public function getUuid(): string
{
return $this->uuid;
}

}

User class implements symfony UserInterface. We have to implement it since loadUserByIdentifier method from provider has to return an object which implements that interface.

To be able to use this provider, we have to specify it in symfony security.yaml file:

    providers:
custom_provider:
id: App\Security\Provider\UserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: '^/api/v1/operation'
provider: custom_provider
access_denied_handler: App\Security\AccessDeniedHandler
custom_authenticators:
- App\Security\ApiTokenAuthenticator

Checking we can subscribe to notifications

Now, to check subscribing to notifications works, let’s create a route which renders a twig template which subscribes to our user payments topic:

<script>
const eventSource = new EventSource("{{ mercure('https://topics.domain.com/65PRG6RD0C87KAQV8RS8H5HHBR/payments')|escape('js') }}");
eventSource.onmessage = event => {
// Will be called every time an update is published by the server
console.log(JSON.parse(event.data));
}
</script>

This is the same script we can see in the documentation. the only change i’ve made is changing the topic IRI. In the topic url we can see the base32 encoded user uuid (65PRG6RD0C87KAQV8RS8H5HHBR).

Now let’s create a simple route which renders that twig:

#[Route('/admin/topics', name: 'get_topics', methods: ['GET'])]
public function getTopics(): Response
{
return $this->render('topics.html.twig');
}

In order to use twig template you will have to install twig bundle: composer2 require symfony/twig-bundle. You can check more about symfony and twig here

Now, let’s send an api request for operation SendPaymentOperation which will publish a notification:

Send payment operation call

Now let’s call /admin/topics route so we can see how message appears in browser console:

Subscribe to notifications

And all its ok, we can subscribe to our topics and see notifications pushed to the hub. It is really important for frontend part since they can provide feedback to the end users.

In the next article, I would ike to show you how to subscribe to the topic using Angular

As always, I hope this content helps you :)

--

--