Nelle moderne architetture nelle quali esiste una netta separazione tra infrastruttura, applicazione e dominio la validazione è un aspetto per niente banale. Infatti bisognerà validare ad ogni livello con meccanismi diversi ponendo particolare attenzione sul proteggere il dominio e i suoi invarianti. In questa presentazione vedremo alcuni principi generali da seguire e come applicarli in un'applicazione realizzata con Symfony. Analizzeremo gli strumenti di validazione già disponibili nel framework e come coniugare la validazione di dominio con quella lato UI.
4. Madisoft
LAYERED Architectures
Separare il codice applicativo e di
dominio dal codice infrastrutturale
Vantaggi:
● Indipendenza dal framework
● Indipendenza dall’infrastruttura
tecnologica
● Indipendenza dai servizi esterni
● Manutenibilità
● TDD
DB Domain
Application
Infrastructure
File
system
Web
Server
7. Madisoft
Browser → Richiesta POST piena di stringhe → Variabili tipizzate e/o Dominio con classi
Request Method: POST
Content-Type:
application/x-www-form-urlencoded
Content:
name=Luigi&year=2021&date=2021-05-13&enabled=1&optional=
[
'name' => "Luigi",
'year' => "2021",
'date' => "2021-05-13",
'enabled' => "1",
'optional' =>
]
int boolean
float string
DateTime
User Book
Order Store
Category
POST ?
8. Madisoft
Come affrontiamoilproblema?
Infrastructure Application Domain
Console
Command
Web
Controller
Api
Controller
Application
Service
Entity
Tipizzazione e completezza
dei dati (+ UI)
Regole di dominio a livello
generale
Regole di dominio a livello di entità
(invarianti)
Symfony Form, Validator e
Serializer
Eccezioni Eccezioni e
Assertion
DTO
string $name;
string $email;
bool $active;
9. Madisoft
GLiInvariantiDiCLASSE
Gli invarianti sono una serie di condizioni che sono sempre vere in una determinata classe
● Definire degli invarianti ci aiuta a formalizzare le specifiche di dominio
● Mantenere gli invarianti significa proteggere il dominio e avere entità sempre in uno
stato valido
● Come si mantengono?
○ Costruttore
○ Metodi che modificano lo stato
○ Ogni informazione che arriva da fuori va quindi validata
11. Madisoft
Bookstore
● Una semplice applicazione per gestire internamente gli ordini di acquisto di un
negozio che spedisce libri in formato digitale
● Per ogni ordine bisogna memorizzare i dati del cliente:
○ Nome e Cognome
○ Email
● Si può acquistare un libro alla volta
● Su ogni ordine può essere applicato uno sconto tra 0 e 50%
● Per tutti gli ordini pagati serve memorizzare l’id del pagamento
● È possibile modificare l’email finché l’ordine non è pagato
12. Madisoft
DOminioeUseCases
● Add Book
● Edit Book
● Create Order
● Edit Order Email
● Add Order Payment
Order
- id: int
- fullName: string
- email: string
- discountPercentage: int
- book: Book
- amountInCents: int
- paid: bool
- paymentId: string
● Show Books
● Show Orders
Book
- title: string
- author: string
- priceInCents: int
0..N 1
13. Madisoft
class Order
{
private int $id;
private string $fullName;
private string $email;
private int $discountPercentage;
private Book $book;
private int $amountInCents;
private bool $paid;
private ?string $paymentId;
}
DomainEntityOrder.php
21. Madisoft
class Order
{
// ...
public function setFullName(?string $fullName): void
{
$this->fullName = $fullName;
}
public function setEmail(?string $email): void
{
$this->email = $email;
}
public function setBook(?Book $book): void
{
$this->book = $book;
}
public function setAmountInCents(int $amountInCents): void
{
$this->amountInCents = $amountInCents;
}
public function setDiscountPercentage(?int $discountPercentage): void
{
$this->discountPercentage = $discountPercentage;
}
}
DomainEntityOrder.php
Manca solo l’importo totale!
22. Madisoft
// …
public function index(Request $request, EntityManagerInterface $em): Response
{
//Costruttore senza parametri e con inizializzazioni di default
$order = new Order();
$form = $this->createForm(CreateOrderType::class, $order);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$bookPrice = $order->getBook()->getPriceInCents();
$amount = intval($bookPrice * (100 - $order->getDiscountPercentage()) / 100);
$order->setAmountInCents($amount);
$em->persist($order);
$em->flush();
$this->addFlash(
'success',
sprintf('Order #%s created successfully', $order->getId())
);
}
return $this->render('create-order.html.twig', [
'form' => $form->createView(),
]);
}
ControllerWebOrderController.php
!??
23. Madisoft
SETTERS+CostruttoreVuoto=Problemi
/**
* @Route("/pay_order", name="pay_order")
*/
public function payOrder(Request $request) {
// get $order from repository ...
$order->setPaid(true);
$this->em->flush();
}
/**
* @Route("/import", name="import")
*/
public function import(Request $request)
{
$order = new Order();
$order->setEmail('invalid-email');
$order->setDiscountPercentage(80);
$order->setAmountInCents(-100);
// save order ...
}
● Rompe la pagina che mostra gli Id di
pagamento degli ordini pagati
● Rompe il flusso che invia i PDF per email
● Altera la pagina dei conteggi totali
Setters + Costruttore Vuoto → Dominio non protetto → Dati Corrotti → Problemi
24. Madisoft
Entità eform
Approccio sbagliato per vari motivi:
● La presenza dei setter e di un costruttore vuoto ci porta ad avere Entità Anemiche
○ Si perde il concetto di invariante
● Anemic Domain Model è un grosso problema!
○ Ci induce a spostare la logica fuori entità (e a replicarla)
○ Rompe l’incapsulamento
● Ci porta ad avere Entità che possono essere create in uno stato invalido
○ Il Form prima riempie l’oggetto e poi valida
○ Gli attributi di classe sono diventati nullabili
○ Qualsiasi flusso che non passa dal form può portare a dati corrotti
○ Nessuna protezione del dominio
26. Madisoft
class Order
{
private int $id;
// Stringa non vuota
private string $fullName;
// Email valida
private string $email;
// Lo sconto deve essere tra 0 e 50
private int $discountPercentage;
// Sempre diverso da null
private Book $book;
// Sempre maggiore di 0
private int $amountInCents;
// true se e solo se PaymentId valorizzato
private bool $paid;
// inizialmente null
private ?string $paymentId;
}
● Una semplice applicazione per gestire
internamente gli ordini di acquisto di un
negozio che spedisce libri in formato
digitale
● Per ogni ordine bisogna memorizzare i dati
del cliente:
○ Nome e Cognome
○ Email
● Si può acquistare un libro alla volta
● Su ogni ordine può essere applicato uno
sconto tra 0 e 50%
● Per tutti gli ordini pagati serve memorizzare
l’id del pagamento
● Deve essere possibile visualizzare
l’importo di tutti gli ordini pagati
● E’ possibile modificare l’email finché
l’ordine non è pagato
27. Madisoft
class Order
{
// properties ...
public function __construct(string $fullName, string $email, Book $book, int $discountPercentage)
{
$this->fullName = $fullName;
$this->email = $email;
$this->book = $book;
$this->discountPercentage = $discountPercentage;
$this->paid = false;
$this->paymentId = null;
$this->amountInCents = intval($book->getPriceInCents() * (100 - $discountPercentage) / 100);
}
}
DomainEntityOrder.php
28. Madisoft
public function __construct(string $fullName, string $email, Book $book, int $discountPercentage)
{
if (empty($fullName)) {
throw new InvalidFullNameException();
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException();
}
if ($discountPercentage > 50 || $discountPercentage < 0) {
throw new InvalidDiscountPercentageException();
}
$this->fullName = $fullName;
$this->email = $email;
$this->book = $book;
$this->discountPercentage = $discountPercentage;
$this->paid = false;
$this->paymentId = null;
$this->amountInCents = intval($book->getPriceInCents() * (100 - $discountPercentage) / 100);
}
DomainEntityOrder.php
30. Madisoft
public function changeEmail(string $email)
{
Assertion::email($email);
if ($this->paid) {
throw new CannotEditPaidOrderException();
}
$this->email = $email;
}
public function addPayment(string $paymentId)
{
Assertion::notEmpty($paymentId);
if ($this->paid) {
throw new OrderAlreadyPaidException();
}
$this->paymentId = $paymentId;
$this->paid = true;
}
// Nessun altro metodo oltre ai getter
DomainEntityOrder.php
31. Madisoft
Entità semprevalidE
● Per come è stata progettata, l’entità Order è sempre valida!
○ Protegge i propri invarianti
● Non c’è modo di crearla in un stato invalido
● Anche usando i metodi pubblici, non c’è modo di portarla in uno stato invalido
● Abbiamo aggiunto un importante strato di protezione
34. Madisoft
/**
* @Route("/create-order", name="create_order")
*/
public function createOrder(OrderService $orderService, Request $request): Response
{
$createOrderDto = new CreateOrderDto();
$form = $this->createForm(CreateOrderType::class, $createOrderDto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$id = $orderService->create($createOrderDto);
$this->addFlash('success', sprintf('Order #%s created successfully', $id));
}
return $this->render('create-order.html.twig', [
'form' => $form->createView(),
]);
}
ControllerWebOrderController.php
Nel FormType dobbiamo aggiornare data_type inserendo CreateOrderDto
35. Madisoft
class OrderService
{
private OrderRepositoryInterface $orderRepository;
private BookRepositoryInterface $bookRepository;
// Costruttore con dependency injection
public function create(CreateOrderDto $createOrderDto)
{
// throws BookNotFoundException
$book = $this->bookRepository->findOneById($createOrderDto->getBookId());
$order = new Order(
$createOrderDto->getFullName(),
$createOrderDto->getEmail(),
$book,
$createOrderDto->getDiscountPercentage(),
);
$this->orderRepository->save($order);
return $order->getId();
}
}
ApplicationOrderService.php
Use Case generico invocabile da
qualsiasi punto: altri controller,
console command, altri service, ecc
?string $fullName;
?string $email;
?int $bookId;
?int $discountPercentage;
36. Madisoft
DTOconcampinullable
● Come possiamo evitarlo?
● Il Form Symfony riempie il DTO e dopo lo
valida
● Cambiamo il flusso!
○ Usiamo il Form solo per validare la
Request
○ Se la Request è valida la
convertiamo nel DTO
● Per la conversione usiamo il Symfony
Serializer
37. Madisoft
/**
* @Route("/create-order", name="create_order")
*/
public function createOrder(OrderService $orderService, Request $request): Response
{
$form = $this->createForm(CreateOrderType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor());
$createOrderDto = $normalizer->denormalize(
$form->getData(),
CreateOrderDto::class
);
$id = $orderService->create($createOrderDto);
$this->addFlash('success', sprintf('Order #%s created successfully', $id));
}
return $this->render('create-order.html.twig', [
'form' => $form->createView(),
]);
}
ControllerWebOrderController.php
string $fullName;
string $email;
int $bookId;
int $discountPercentage;
38. Madisoft
Come cambia la validazione in un controller
API che riceve un JSON?
Riusare lo stesso Symfony Form ?
Usare il Param Converter abbinato a FOSRestBundle ?
Oppure:
Usare il Validator e il Serializer di Symfony
/**
* @Route("/api/create-order", name="api_create_order")
* @ParamConverter("createOrderDto", converter="fos_rest.request_body")
*/
public function createOrder(
CreateOrderDto $createOrderDto, ConstraintViolationListInterface $validationErrors
) {
// check $validationErrors and process $createOrderDto
}
39. Madisoft
class ApiOrderController extends AbstractController
{
/**
* @Route("/api/create-order", name="api_create_order")
*/
public function createOrder(Request $request, ValidatorInterface $validator) {
$constraints = new Collection([
'fullName' => new NotBlank(),
'email' => [new NotBlank(), new Email()],
'discountPercentage' => [new NotBlank(), new Range(['min' => 0, 'max' => 50])],
'bookId' => [new NotBlank(), new Type('int')]
]);
$violations = $validator->validate($request->toArray(), $constraints);
if (count($violations) > 0) {
return new JsonResponse(['validationErrors' => $this->formatErrors($violations)], 400);
}
$normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor());
$createOrderDto = $normalizer->denormalize(
$request->toArray(),
CreateOrderDto::class
);
$orderId = $this->orderService->create($createOrderDto);
return new JsonResponse(['orderId' => $orderId]);
}
}
ControllerApiOrderController.php
Validazione
Conversione
40. Madisoft
REQUEST:
POST http://127.0.0.1:8080/api/create-order
Content-Type: application/json
{
"name": "Luigi Cardamone",
"email": "luigi@local.local",
"discountPercentage": 100,
"bookId": "1"
}
RESPONSE:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"validationErrors": [
"[fullName] => This field is missing.",
"[discountPercentage] => This value should be between 0 and 50.",
"[bookId] => This value should be of type int.",
"[name] => This field was not expected."
]
}
42. Madisoft
Validazionereplicata
● Nell’esempio precedente, i seguenti due controlli sono replicati:
○ DiscountPercentage tra 0 e 50
○ Formato dell’email valida
● Sono controllati sia nel Dominio che nel Controller: come posso evitarlo?
○ Rimuovo il controllo dal Dominio?
○ Lo rimuovo dal controller?
● Se rimuovo dal controller, tutto il flusso funziona ma al primo errore di validazione ottengo
una pagina di errore: come posso risolvere?
○ Tipizzare le eccezioni (UserReadableException)
○ Intercettare le eccezioni nei controller e mostrarle nel Form o nel JSON
43. Madisoft
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$id = $orderService->create($createOrderDto);
$this->addFlash('success', sprintf(Order #%s created', $id));
} catch (UserReadableException | AssertionFailedException $e) {
$form->addError(new FormError($e->getMessage()));
}
}
return $this->render('create-order.html.twig', [
'form' => $form->createView(),
]);
ControllerWebOrderController.php
Idem nel caso del Controller API: mappiamo le eccezioni nell’array validationErrors
44. Madisoft
Quandoreplicare?
● Gli errori UI non sono più paralleli ma diventano sequenziali
● Bisogna replicare i controlli di validazione solo quando c’è un valore aggiunto
per l’esperienza utente. Scegliere in base al contesto:
○ Pagina di signup
○ Api per un Web Service
○ Comando CLI
● La protezione sul dominio ci da flessibilità
● La nostra applicazione non dipende più dalla Validazione UI
○ Può anche essere spostata totalmente nel FE
45. Madisoft
CONCLUSIONI
● Validazione multi livello
● Astrarre il concetto di richiesta
● Invertire deserializzazione e
validazione
● Protezione del dominio! Sempre!
46. Madisoft
CREDITS: This presentation template was created by Slidesgo,
including icons by Flaticon, and infographics & images by
Freepik and illustrations by Stories
Grazie!
Domande?
@CardamoneLuigi
labs.madisoft.it