I am starting out in the Mercure hub job but I have some gaps that do not allow me to advance; the idea is to add to the application (it is a traditional information management web application with symfony 5.4 that I have been updating from sf3) the classic notification (bell) to the users before a certain action of any of the users, in this specific case When registering a new transfer request for a pregnant woman, the bell must be activated in the dashboard of the user who belongs to the health unit where the pregnant woman is currently registered for approval. It is a simple functionality but it will give me the idea to add more complex real-time notifications.
So far this is what I have and the doubts that have arisen:
composer require mercure
I downloaded the mercure executable for Windows.
2 - Create the necessary environment variables:
//env.local.php:
return array(
'MESSENGER_TRANSPORT_DSN' => 'doctrine://default',
'MERCURE_URL' => 'https://localhost:3000/.well-known/mercure',
'MERCURE_PUBLIC_URL' => 'https://pami.local:3000/.well-known/mercure',
'MERCURE_JWT_SECRET' => 'm3rcu353cr37pa55pra53DEV'
);
As I understand it, MERCURE_URL is the communication address of the web server with the mercure hub, and MERCURE_PUBLIC_URL is the address for the subscription from the client side to the hub. The first question, based on what data should I generate the MERCURE_JWT_SECRET ??? This will be a stateless token?? stored in an environment variable and shouldn't change??
Having the environment variables defined, this is the mercure.yaml recipe :
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
Doubt : I don't understand the composition of the JWT
, in the format of the mercure bundle, what should the publish section of this recipe contain?
This is the action that should generate the notification, for this I do the following:
/**
*
* @param Request $request
* @param ManagerRegistry $manager
* @param UserInterface $user
* @param UuidEncoder $uuidEncoder
* @param LoggerInterface $logger
* @param \Symfony\Component\Messenger\MessageBusInterface $messageBus
* @return Response
* @throws Exception
* @throws type
*/
public function solicitarTrasladoAction(Request $request, ManagerRegistry $manager, UserInterface $user, UuidEncoder $uuidEncoder, LoggerInterface $logger, \Symfony\Component\Messenger\MessageBusInterface $messageBus): Response
{
if ($request->isXmlHttpRequest()) {
try {
if (!$request->isMethod(Request::METHOD_POST)) {
return new Response("Operación no soportada!!!", 500);
}
$id = $uuidEncoder->decode($request->request->get('embarazadaId', null));
$cmfDestinoId = $uuidEncoder->decode($request->get('cmfDestino', null));
$em = $manager->getManager();
$nuevaSolicitudTraslado = new \App\Entity\SolicitudTrasladoEmbarazada();
$conn = $em->getConnection();
$conn->beginTransaction();
try {
$cmfDestino = $manager->getRepository(EstructuraOrganizativa::class)->findOneJoinTipoEstructuraOrganizativa($cmfDestinoId);
if (\is_null($cmfDestino)) {
throw new \Exception("No se encontró la unidad de destino.", 404);
} else if ($cmfDestino->getTipoEstructuraOrganizativa()->getId() !== 6) {
throw new \Exception("No es posible ubicar una embarazada fuera de un CMF.", 406);
}
$embarazada = $em->getRepository(Embarazada::class)->findOneJoinEstructuraOrganizativa($id);
if (\is_null($embarazada)) {
throw new \Exception("No se encontró la embarazada solicitada", 404);
}
if ($embarazada->getEstructuraOrganizativa()->getId() === $cmfDestino->getId()) {
throw new \Exception("No es posible reubicar la embarazada en el CMF al que pertenece actualmente.", 406);
}
$posibleSolicitud = $em->getRepository(\App\Entity\SolicitudTrasladoEmbarazada::class)->findOneBy(['embarazada' => $embarazada, 'estado' => 'solicitado']);
if (!\is_null($posibleSolicitud)) {
throw new \Exception("Ya existe una solicitud de traslado para esta paciente.", 406);
}
$nuevaSolicitudTraslado->setEmbarazada($embarazada);
$nuevaSolicitudTraslado->setCmfDestino($cmfDestino);
$nuevaSolicitudTraslado->setEstado("solicitado");
$nuevaSolicitudTraslado->setAsunto("Solicitud de traslado");
$nuevaSolicitudTraslado->setMensaje(\sprintf("Solicito reubicar a '%s' hacia provincia '%s', municipio '%s', CMF: %s.", $embarazada->getNombre(), $cmfDestino->getParent()->getParent()->getParent()->getParent()->getTitle(), $cmfDestino->getParent()->getParent()->getParent()->getTitle(), $cmfDestino->getTitle()));
$em->persist($nuevaSolicitudTraslado);
$em->flush();
$conn->commit();
} catch (\Exception $exc) {
$conn->rollback();
$conn->close();
if (in_array($exc->getCode(), array(404, 406))) {
return new Response($exc->getMessage(), 500);
}
$logger->error(sprintf("[%s:%s]: %s", __CLASS__, __FUNCTION__, $exc->getMessage()));
return new Response("Ocurrió un error inesperado al ejecutar la operación", 500);
}
$messageBus->dispatch(new \App\Message\NotificacionSolicitudTrasladoEmbarazadaMessage($uuidEncoder->encode($nuevaSolicitudTraslado->getIdPublico())));
return new Response("La solicitud de traslado fue enviada satisfactoriamente");
} catch (\Exception $exc) {
$logger->error(sprintf("[%s:%s]: %s", self::class, __FUNCTION__, $exc->getMessage()));
return new Response("Ocurrió un error inesperado al ejecutar la operación", 500);
}
} else {
throw $this->createNotFoundException("Recurso no encontrado");
}
}
Before sending the response to the user that the request was generated correctly and to achieve asynchronous behavior, I add a message to the configured symfony messaging component (in this case I use Doctrine as transport), passing in its content the idPublico of the new request record created:
// contenido de la receta messenger.yaml
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
failure_transport: failed
reset_on_message: true
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
auto_setup: false
failed:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: 'failed'
sync: 'sync://'
routing:
# Route your messages to the transports
'App\Message\NotificacionSolicitudTrasladoEmbarazadaMessage': async
This is the class that represents the simple message:
namespace App\Message;
class NotificacionSolicitudTrasladoEmbarazadaMessage
{
private $content;
public function __construct(string $content)
{
$this->content = $content;
}
public function getContent(): string
{
return $this->content;
}
}
And this is the handler of said message, I understand this is where the logic of the mercure hub would continue:
use App\Message\NotificacionSolicitudTrasladoEmbarazadaMessage;
use App\Repository\SolicitudTrasladoEmbarazadaRepository;
use App\Services\UuidEncoder;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Envia al Mercure bus la nueva solicitud de traslado para ser notificada a los usuarios
*/
class NotificacionSolicitudTrasladoEmbarazadaMessageHandler implements MessageHandlerInterface
{
private $mercureHub;
private $repositorySolicitudTrasladoEmbarazada;
private $uuidEncoder;
public function __construct(HubInterface $mercureHub, SolicitudTrasladoEmbarazadaRepository $repositorySolicitudTrasladoEmbarazada, UuidEncoder $uuidEncoder)
{
$this->mercureHub = $mercureHub;
$this->repositorySolicitudTrasladoEmbarazada = $repositorySolicitudTrasladoEmbarazada;
$this->uuidEncoder = $uuidEncoder;
}
public function __invoke(NotificacionSolicitudTrasladoEmbarazadaMessage $message)
{
// hacer algo con el mensaje, por ejemplo: enviar una notificacion al hub de mercure
$idPublico = $this->uuidEncoder->decode($message->getContent());
$solicitud = $this->repositorySolicitudTrasladoEmbarazada->findOneBy(['idPublico' => $idPublico]);
/** si no existe el registro hacer fallar el mensaje* */
/** Contar la cantidad de solictudes aun no atendidas para la unidad de destino, se debe mostrar al lado de la campanita en la UI * */
$totalNoAtendidas = $this->repositorySolicitudTrasladoEmbarazada->contarNoAtendidas($solicitud->getCmfDestino());
$actualizacion = new Update(
'https://the-uri-of-resource', // Esta URI debe ser generada con el sistema de enrutamiento interno de symfony??
\json_encode(['ultimaSolictud' => $solicitud->getAsunto(), 'totalNoAtendidas' => $totalNoAtendidas]),
true // privado necesita jwt auth
);
$this->mercureHub->publish($actualizacion);
return new Response("Publicado");
}
}
Doubt : The URI parameter passed to the class Update
should be a url generated with symfony routing?? or is it a formality?
Up to this point the messages remain stored in the message queue, retrying to send to the mercure hub because the publish() fails due to the configuration problems that I pointed out above and therefore the mercure.exe does not run correctly. In the case of the client side, I still don't quite understand the configure part of setting the JWT, I'll leave that for another question.
Regarding the questions you have:
In this aspect you can find the complete configuration in https://symfony.com/doc/current/mercure.html , but in this case you are allowing to publish in all mercure buckets.
Regarding this question:
It seems that if it is mandatory, you can take this course: https://symfonycasts.com/screencast/turbo/mercure-php and you can generate the url from the parameter
I already found the solution, I still have to understand some concepts of the Symfony Messenger component that help to decouple the code, but for this case at least it helps me to start:
The main problem was in the
recipe
Mercure one, leaving the file as followsmercure.yaml
:In both publish and subscribe I set the name of the topic under which it will be published in the Mercure hub, and to which we will subscribe from the application.
These two settings ensure that the JWT is properly generated in the format expected by Mercure.
Another aspect, the application is accessible via HTTPS, therefore the environment variables
MERCURE_URL
andMERCURE_PUBLIC_URL
must point to the same domain, in this case it is a virtual host with a fictitious domain, making it possible to exchange the session cookie between the web application and the server. mercury hub These variables remain in the file.env.local
: as follows:I made the other adaptation in the Caddy file of the Mercure binary, leaving it as follows:
Important to note about this file:
SERVER_NAME
the domain name by which the Mercure hub will be called and the port..crt
.key
CORS
Finally, on the client side in the view
twig
, an object of type EventSource is created implementing the associated events, for this it is completed with the url of the Mercure server (through the twig function that implements the Mercure bundle) and theURI
topic to which we will subscribe:With these settings, the "real-time notification" of the number of pending requests that are being registered works properly.