17-04-2023
Introducció
Suposem que tenim el mateix endpoint que en el post anterior (per simplicitat, només en mostrem la resposta correcta):
- Url:
GET /llibres/{uuid}
- Resposta correcta
- Codi HTTP: 200
- Contingut:
{ "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5", "titol": "Curial e Güelfa", "autor": "Anònim" }
El controlador, havent refactoritzat la gestió d’excepcions, quedava així:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Ui\Http\Controller;
use rubenrubiob\Application\Query\Llibre\GetLlibreDTOByIdQuery;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final readonly class GetLlibreController
{
public function __construct(private QueryBus $queryBus)
{
}
public function __invoke(string $llibreId): Response
{
/** @var LlibreDTO $llibreDTO */
$llibreDTO = $this->queryBus->__invoke(
new GetLlibreDTOByIdQuery(
$llibreId
)
);
return new JsonResponse(
[
'id' => $llibreDTO->llibreId->toString(),
'titol' => $llibreDTO->llibreTitol->toString(),
'autor' => $llibreDTO->autorNom->toString(),
],
Response::HTTP_OK,
);
}
}
Suposem ara que tenim un altre endpoint que retorna un llistat de llibres, descrit com el següent:
- Url:
GET /llibres
- Resposta correcta
- Codi HTTP: 200
- Contingut:
[ { "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5", "titol": "Curial e Güelfa", "autor": "Anònim" }, { "id": "4f9d75b7-5dd4-4d19-8a31-8876d54cddee", "titol": "Tirant Lo Blanc", "autor": "Joanot Martorell" }, ]
El controlador que gestiona aquest endpoint és el següent:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Ui\Http\Controller;
use rubenrubiob\Application\Query\Llibre\FindLlibreDTOsQuery;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use function array_map;
final readonly class FindLlibresController
{
public function __construct(private QueryBus $queryBus)
{
}
public function __invoke(): Response
{
/** @var list<LlibreDTO> $llibres */
$llibres = $this->queryBus->__invoke(
new FindLlibreDTOsQuery()
);
$formattedResponse = array_map(
fn(LlibreDTO $llibreDTO): array =>
[
'id' => $llibreDTO->llibreId->toString(),
'titol' => $llibreDTO->llibreTitol->toString(),
'autor' => $llibreDTO->autorNom->toString(),
],
$llibres,
);
return new JsonResponse(
$formattedResponse,
Response::HTTP_OK,
);
}
}
Veiem que, en ambdós casos, el que fem és formatar un LlibreDTO
, en el primer cas, directament, i, en el segon, dins
d’un array
.
Ara bé, suposem que hem d’afegir un nou camp al LlibreDTO
que hem de retornar a totes les respostes on s’empra. Quin
problema ens trobem? Que l’hem d’afegir a diversos cops, tants com controladors on es faci servir. Què passa si oblidem
afegir el camp a un endpoint? Que la nostra API retornarà respostes diferents per al mateix element.
Un bon disseny d’API requereix donar respostes uniformes, és a dir, que els elements es representin de la mateixa manera arreu. Per a aconseguir-ho, cal treballar molt el projecte amb gent que el conegui, veure les dades existents i els requisits del domini. És una feina de paciència. Un estàndard com OpenAPI ens pot ajudar en aquesta tasca de definició1.
Pel que fa a la implementació de l’endpoint, igual que vam fer amb les excepcions al post anterior, podem delegar el formatat de les respostes al framework.
kernel.view
esdeveniment
Tal com vam explicar, el Kernel
de Symfony fa servir esdeveniments en què el desenvolupador pot fer accions:
Veiem que hi ha dos punts que s’executen després del controlador:
- El punt 5, si el controlador retorna un objecte
Response
. - El punt 6, si el controlador no retorna un objecte
Response
. S’executa l’esdevenimentview
.
Consultant
la documentació de Symfony, veiem que aquest esdeveniment ens permet processar el valor de resposta d’un controlador per a obtenir
un objecte Response
:
If the controller doesn’t return a
Response
object, then the kernel dispatches another event -kernel.view
. The job of a listener to this event is to use the return value of the controller (e.g. an array of data or an object) to create aResponse
.
Així doncs, igual que vam fer amb les excepcions, podem delegar i centralitzar la presentació de les dades que retorna el controlador a aquest esdeveniment.
Implementació
Per a convertir les dades que retorna el controlador farem servir un serialitzador. El serialitzador s’encarregarà de
cridar un servei que anomenarem Presenter
, que serà el que formatarà els nostres objectes.
LlibreDTOPresenter
Aquesta classe es limita a rebre un LlibreDTO
i a formatar-lo. És el servei que centralitza la presentació del nostre
objecte de domini.
Per a cada element del nostre domini que presentem, haurem de crear un nou Presenter
.
En aquest cas, cal tenir en compte que aquest Presenter
és per a JSON. Si volguéssim presentar l’element en algun
altre format, necessitaríem un altre Presenter
.
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Ui\Http\Response\Presenter;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
final readonly class LlibreDTOPresenter
{
/** @return array{
* id: non-empty-string,
* titol: non-empty-string,
* autor: non-empty-string
* }
*/
public function __invoke(LlibreDTO $llibreDTO): array
{
return [
'id' => $llibreDTO->llibreId->toString(),
'titol' => $llibreDTO->llibreTitol->toString(),
'autor' => $llibreDTO->autorNom->toString(),
];
}
}
Symfony Serializer
Per a gestionar la presentació de dades a l’esdeveniment kernel.view
emprarem
el Serializer de Symfony. Aquest serialitzador fa
servir normalitzadors per a convertir objectes
a array
, i viceversa. El serialitzador permet afegir nous normalitzadors.
Podem aprofitar aquest fet per a afegir un normalitzador per a passar el nostre LlibreDTO
a JSON. Seria com aquest2:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Ui\Http\Response\Serializer;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Infrastructure\Ui\Http\Response\Presenter\LlibreDTOPresenter;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use function assert;
final readonly class LlibreDTOJsonNormalizer implements NormalizerInterface
{
private const FORMAT = 'json';
public function __construct(
private LlibreDTOPresenter $presenter
) {
}
/** @param array<array-key, mixed> $context */
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
return $format === self::FORMAT && $data instanceof LlibreDTO;
}
/**
* @param array<array-key, mixed> $context
*
* @return array{
* 'id': non-empty-string,
* 'titol': non-empty-string,
* 'autor': non-empty-string
* }
*/
public function normalize(mixed $object, string $format = null, array $context = []): array
{
assert($object instanceof LlibreDTO);
return $this->presenter->__invoke($object);
}
}
Per a cada objecte que vulguem presentar ens caldrà afegir un normalitzador. Per a evitar això, podríem implementar un
factory que contingui tots els Presenter
i fer-lo servir als dos mètodes del normalitzador.
Si fem servir l’autoconfiguració per a definir els serveis de Symfony, el nostre normalitzador ja estarà automàticament afegit al serialitzador per defecte que incorpora el framework.
ViewResponseSubscriber
Amb el Presenter
i el serialitzador ja configurats, podem implementar el Subscriber
que gestiona
l’esdeveniment kernel.view
:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Symfony\Subscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class ViewResponseSubscriber implements EventSubscriberInterface
{
private const HEADERS = ['Content-Type' => 'application/json'];
private const FORMAT = 'json';
public function __construct(
private SerializerInterface $serializer
) {
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::VIEW => '__invoke'];
}
public function __invoke(ViewEvent $event): void
{
$controllerResult = $event->getControllerResult();
if ($controllerResult === null) {
$event->setResponse(
new Response(
null,
Response::HTTP_NO_CONTENT,
self::HEADERS,
)
);
return;
}
$response = new Response(
$this->serializer->serialize(
$event->getControllerResult(),
self::FORMAT,
),
Response::HTTP_OK,
self::HEADERS,
);
$event->setResponse($response);
}
}
El que fem és establir com a contingut de la resposta el valor serialitzat que retorna el controlador.
Cal tenir en compte que és una implementació un xic simplista: sempre s’estableix el codi de resposta HTTP 200 si el
controlador retorna un valor diferent de null
; si no, retorna un 204, de No Content
. Si calgués retornar altres
codis, com el 201 de Created
, caldria afegir lògica per a controlar-ho.
També cal tenir en compte que el format de resposta és sempre JSON. En el futur veurem com fer flexible també aquest format de resposta.
Si fem servir l’autoconfiguració per a definir els serveis de Symfony, el nostre Subscriber
ja estarà automàticament
configurat a l’aplicació.
Controladors simplificats
Amb el Subscriber
complet i funcionant, ja podem simplificar els nostres controladors.
El controlador que retorna un únic llibre queda així:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Ui\Http\Controller;
use rubenrubiob\Application\Query\Llibre\GetLlibreDTOByIdQuery;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
final readonly class GetLlibreController
{
public function __construct(private QueryBus $queryBus)
{
}
public function __invoke(string $llibreId): LlibreDTO
{
return $this->queryBus->__invoke(
new GetLlibreDTOByIdQuery(
$llibreId
)
);
}
}
El controlador que retorna un llistat de llibres queda així:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Ui\Http\Controller;
use rubenrubiob\Application\Query\Llibre\FindLlibreDTOsQuery;
use rubenrubiob\Domain\DTO\Llibre\LlibreDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
final readonly class FindLlibresController
{
public function __construct(private QueryBus $queryBus)
{
}
/** @return list<LlibreDTO> */
public function __invoke(): array
{
return $this->queryBus->__invoke(
new FindLlibreDTOsQuery()
);
}
}
Veiem que als controladors es limiten a cridar i retornar el QueryBus, donat que la gestió tant dels errors com del formatat de la resposta s’han delegat a esdeveniments del framework.
Conclusions
Delegant la gestió de les respostes al framwork aconseguim centralitzar el formatat de respostes. Si cal afegir un
camp nou, amb afegir-lo en un únic lloc, el Presenter
, n’hi haurà prou perquè s’actualitzi a tots els controladors.
Cal tenir en compte, això sí, que caldrà actualitzar els tests funcionals que hi hagi per tenir en compte aquest canvi.
A més a més, juntament amb la gestió d’excepcions que vam veure al post anterior, hem simplificat els nostres controladors perquè tinguin la mínima lògica possible: rebre una petició i retornar una resposta, res més.
Aquesta solució no impossibilita retornar una resposta directament des del controlador, ja que
l’esdeveniment kernel.view
només s’executa quan el controlador no retorna un objecte Response
.
Resum
- Hem vist el problema que suposa no tenir el formatat de resposta centralitzat.
- Hem introduït els
Presenters
per a formatar els nostres objectes de domini. - Hem configurat el serialitzador de Syfmony amb un normalitzador per a presentar els nostres objectes.
- Hem creat un
Subscriber
per a l’esdevenimentkernel.view
per a gestionar les respostes d’una API de manera centralitzada. - Hem simplificat els nostres controladors perquè tinguin la mínima lògica possible.
-
Per a bones pràctiques en el disseny d’API, vegeu per exemple aquest article de Swagger o aquest altre d’Stack Overfow. ↩
-
A Symfony 5.4 la interfície
NormalizerInterface
varia una mica. Es pot fer servir en el seu llocContextAwareNormalizer
. ↩