11 - Validazione
Tipizzazione dei controller
Sviluppando le API ci siamo resi conto che dobbiamo spesso andare a leggere dati da req.body, req.query e req.params, ma typescript al momento non ha idea di quali dati ci siano a disposizione in una specifica richiesta.
Se andiamo a vedere com'è definita l'interfaccia Request di express ci accorgiamo che è definita tramite dei generics, e ci permette di fornire i tipi di ReqBody, ReqQuery e P che sono i params.
Siccome non è comodo andare a definire tutti i parametri per tipizzare i body possiamo andare a creare noi una nuova interfaccia che estende quella base e ci semplifica l'utilizzo:
typed-request.interface.ts
import { Request } from 'express';
import { ParamsDictionary } from 'express-serve-static-core';
import { ParsedQs } from 'qs';
export interface TypedRequest<T = unknown, Q = ParsedQs, P = ParamsDictionary>
extends Request<P, any, T, Q> { };
export { ParsedQs, ParamsDictionary };
In questo modo nella maggior parte dei casi (quelli in cui voglio accedere esclusivamente al body), posso passargli solo il primo tipo, altrimenti posso andare a definire tutto.
Una funzione del controller tipizzata correttamente a questo punto diventa ad esempio:
export const add = async (
req: TypedRequest<AddCartItemDTO>,
res: Response,
next: NextFunction) => {
try {
const { productId, quantity } = req.body;
const product = await productService.getById(productId);
if (!product) {
throw new NotFoundError();
}
const newItem: CartItem = {
product: productId,
quantity
};
const saved = await cartItemService.add(newItem);
res.json(saved);
} catch(err) {
next(err);
}
}
Da notare che tutti i tipi del generics hanno un valore predefinito, questo ci permette di usare TypedRequest esattamente come usavamo Request (senza specificare i tipi), e accedere ad oggetti generici.
Se invece vogliamo obbligare l'utilizzatore a tipizzare correttamente le richieste possiamo forzare l'utilizzo modificando l'interfaccia in:
import { Request } from 'express';
import { ParamsDictionary } from 'express-serve-static-core';
import { ParsedQs } from 'qs';
export interface TypedRequest<T = unknown, Q = unknown, P = unknown>
extends Request<P, any, T, Q> { };
export { ParsedQs, ParamsDictionary };
In questo modo ho la possibilità di usare TypedRequest senza definire i tipi, ma nel momento in cui provo ad accedere a delle proprietà degli oggetti il compilatore mi da errore, obbligandomi di fatto a definire i tipi delle parti della richiesta che mi servono per la mia logica.
Validazione
Intro
npm i class-validator class-transformer
class-transformer ha bisogno di reflect-metadata per funzionare:
npm i reflect-metadata
aggiungere come prima riga del file index.ts import reflect-metadata;
questa libreria permette di usare i decorator di typescript per imporre delle condizioni di validazione alle proprietà di una classe.
Per usarla dobbiamo usare delle classi, a differenza delle interfacce che abbiamo già creato.
Solitamente i dati che accettiamo nel body delle richieste hanno strutture diverse da quelli che salviamo o torniamo come risultati dalle api. Per questo motivo vengono create classi apposta che si usano solo per interpretare e validare le richieste, comunemente chiamate DTO (Data Transfer Object)
- c'è un problema con le classi, dice che le proprietà non vengono inizializzate, per risolverlo dobbiamo impostare typescript con
strictPropertyInitialization: false - altro problema, da errori sui decoratori, bisogna aggiungere anche
"experimentalDecorators": true
cart-item.dto.ts
import { IsMongoId, IsInt } from "class-validator";
export class CreateCartItemDTO {
@IsMongoId()
productId: string;
@IsInt()
quantity: number;
}
export const add = async (
req: TypedRequest<CreateCartItemDTO>,
res: Response,
next: NextFunction) => {
try {
const data = plainToClass(CreateCartItemDTO, req.body);
const errors = await validate(data);
if (errors.length > 0) {
console.log(errors);
next(errors);
}
Esercizio
provate a creare una classe per l'errore e l'handler che la gestisce. Voglio che al client torni come risultato:
{
"name": "ValidationError",
"message": "productId must be a mongodb id; quantity must be an integer number",
"details": [
{
"property": "productId",
"constraints": {
"isMongoId": "productId must be a mongodb id"
},
"value": "649eaa7bd86b8a515f5f6a4"
},
{
"property": "quantity",
"constraints": {
"isInt": "quantity must be an integer number"
},
"value": "test"
}
]
}
per farlo dovete:
- tenere presente che validate torna un array di errori e che dobbiamo tenere traccia dei vari dettagli (quindi il costruttore della classe accetterà gli errori originali e li terrà in memoria)
- andare a vedere nella documentazione di class-validator che proprietà hanno gli errori
- concatenare i vari messaggi e comporre un message unico
- tornare anche una proprietà details che torni i singoli errori di validazione che si sono verificati
validation.ts
import { ValidationError as OriginalValidationError } from "class-validator";
import { NextFunction, Request, Response } from "express";
export class ValidationError extends Error {
originalErrors: OriginalValidationError[];
constructor(errors: OriginalValidationError[]) {
super();
this.name = 'ValidationError';
this.originalErrors = errors;
this.message = this.originalErrors.map(err => Object.values(err.constraints as any)).join('; ');
}
}
export const validationErroHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ValidationError) {
res.status(400);
res.json({
name: err.name,
message: err.message,
details: err.originalErrors.map(e => ({
property: e.property,
constraints: e.constraints,
value: e.value
}))
})
} else {
next(err);
}
}
Validation Middleware
Andiamo ora a creare un metodo che torna un middleware per la validazione, in questo modo non dobbiamo gestirla all'interno del controller.
validate-middleware.ts
import { plainToClass } from "class-transformer";
import { validate as classValidate } from "class-validator";
import { NextFunction, Request, Response } from "express"
import { ValidationError } from "../errors/validation";
export const validate = <T extends object>(type: (new() => T), origin: 'body' | 'query' = 'body') => {
return async (req: Request, res: Response, next: NextFunction) => {
const data = plainToClass(type, req[origin]);
const errors = await classValidate(data);
if (errors.length > 0) {
next(new ValidationError(errors));
} else {
next();
}
}
}
Esercizio:
andare a gestire allo stesso modo la validazione dell'updateQuantity. Bisogna creare una nuova classe DTO.
Validazione req.query
Facciamo un ulteriore passo avanti e andiamo a fare in modo che la nostra funzione validate modifichi il contenuto di req.body o req.query con l'oggetto trasformato.
Quando andiamo a tipizzare le nostre richieste ci accorgiamo che sui router ci da dei problemi di compatibilità tra i tipi utilizzati. Questo perché nella funzione validate torniamo un middleware che ha come tipo Request generico, mentre sui controller andiamo ad usare un tipo più restrittivo.
Per correggere questo problema possiamo mettere su validate TypedRequest<any, any>. Però in realtà quel controllo potrebbe tornarci utile, ed è una buona occasione per andare a vedere come funziona l'overload dei metodi in typescript (anche se questo è un caso un po' complesso).
import { plainToClass } from "class-transformer";
import { validate as classValidate } from "class-validator";
import { NextFunction, Response } from "express"
import { ValidationError } from "../errors/validation";
import { TypedRequest } from "./typed-request.interface";
function validateFn<T extends object>(
type: (new() => T),
origin: 'body'): (req: TypedRequest<T>, res: Response, next: NextFunction) => Promise<void>
function validateFn<T extends object>(
type: (new() => T),
origin: 'query'): (req: TypedRequest<unknown, T>, res: Response, next: NextFunction) => Promise<void>
function validateFn<T extends object>(
type: (new() => T)): (req: TypedRequest<T>, res: Response, next: NextFunction) => Promise<void>
function validateFn<T extends object>(
type: (new() => T),
origin: 'body' | 'query' = 'body'): ((req: TypedRequest<T>, res: Response, next: NextFunction) => Promise<void>) | ((req: TypedRequest<any, T>, res: Response, next: NextFunction) => Promise<void>)
{
return async (req: TypedRequest<any, any>, res: Response, next: NextFunction) => {
const data = plainToClass(type, req[origin]);
const errors = await classValidate(data);
if (errors.length > 0) {
next(new ValidationError(errors));
} else {
req[origin] = data;
next();
}
}
}
export const validate = validateFn;
Esercizio:
andare a creare la classe DTO per i filtri e validare le richieste:
- name deve essere una stringa
- minPrice deve essere maggiore di 0
- maxPrice deve essere maggiore di minPrice, se esiste minPrice, altrimenti di 0
- suggerimento 1: per maxPrice bisogna creare un validatore custom, andate a vedere nella documentazione come funziona, c'è un esempio molto simile a quello che dobbiamo fare
- suggerimento 2: da req.query arrivano su sempre stringhe, per farlo funzionare correttamente bisogna aggiungere
@Type(() => Number)alle proprietà che devono essere trasformate in numeri. Vedere la documentazione diclass-transformerper maggiori chiarimenti.