Fastify: Type safe con i Type-Providers
In questo articolo vedremo come tipizzare automaticamente le rotte Fastify tramite il JSON schema, utilizzando il Type-Provider: TypeBox .
Table of contents
- Pre requisiti
- Cosa sono i Type-Providers
- Creiamo il progetto Fastify con Typescript
- Struttura del progetto
- Contenuto del package.json generato
- Installiamo le dipendenze del package.json
- Rotta root default
- Aggiungiamo la nostra rotta
- Errore Typescript
- Installiamo la libreria type-provider-typebox
- Creiamo lo schema con Typebox
- Usiamo lo schema per generare i tipi per la rotta
- Refactoring
- Tipizzazione dell'handler della rotta
Pre requisiti
Node.js v20.13.1
Cosa sono i Type-Providers
Documentazione: Type-Providers
I Type-Providers sono una feature solo per i progetti Fastify che utilizzano Typescript come linguaggio.
Ci sono diversi tipi di Type-Providers, per questo articolo utilizzeremo: TypeBox
Creiamo il progetto Fastify con Typescript
Apriamo il terminale e digitiamo questo comando:
npx fastify-cli generate my-app --lang=ts
Questo comando genererà un progetto Fastify con Typescript, utilizzando l'utility fastify-cli
Struttura del progetto
A questo punto abbiamo un progetto con la seguente struttura:
Contenuto del package.json generato
{
"name": "my-app",
"version": "1.0.0",
"description": "This project was bootstrapped with Fastify-CLI.",
"main": "app.ts",
"directories": {
"test": "test"
},
"scripts": {
"test": "npm run build:ts && tsc -p test/tsconfig.json && c8 node --test -r ts-node/register \"test/**/*.ts\"",
"start": "npm run build:ts && fastify start -l info dist/app.js",
"build:ts": "tsc",
"watch:ts": "tsc -w",
"dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"",
"dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/autoload": "^5.0.0",
"@fastify/sensible": "^5.0.0",
"@fastify/type-provider-typebox": "^4.0.0",
"fastify": "^4.26.1",
"fastify-cli": "^6.2.1",
"fastify-plugin": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.4.4",
"c8": "^9.0.0",
"concurrently": "^8.2.2",
"fastify-tsconfig": "^2.0.0",
"ts-node": "^10.4.0",
"typescript": "^5.2.2"
}
}
Installiamo le dipendenze del package.json
Entriamo dentro alla cartella del progetto "my-app" appena creato e installiamo tutte le dipendenze del package.json con questo comando:
npm install
Rotta root default
fastify-cli ha già creato per noi 2 rotte, le possiamo trovare dentro i file:
src/routes/root.ts
src/routes/example/index.ts
Vediamo il codice generato per la rotta root:
import { FastifyPluginAsync } from "fastify"
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return 'this is an example'
})
}
export default example;
Questa rotta definisce una chiamata HTTP GET che come riposta restituisce una stringa: 'this is an example'.
Aggiungiamo la nostra rotta
Per fare un esempio il più semplice possibile, Aggiungiamo una rotta che prende in input del testo e come risposta ritorna il testo tutto in maiuscolo.
import { FastifyPluginAsync } from "fastify"
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return 'this is an example'
})
fastify.post('/uppercase', async function (request, reply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
})
}
export default example;
Abbiamo aggiunto una rotta di tipo POST, la rotta si aspetta nel body una sola proprietà 'text' e ritorna un oggetto con un unica proprietà 'textResult'.
Errore Typescript
A questo punto Typescript genererà un errore, perché inizialmente body è typo unknown, quindi dobbiamo tipizzare l'oggetto body.
Potremmo tipizzare l'oggetto body direttamente con un interfaccia Typescript, ma siccome useremo uno schema per validare la rotta, lasceremo generare i tipi Typescript direttamente dal nostro schema, così da avere una sola fonte della verità.
Installiamo la libreria type-provider-typebox
npm i @fastify/type-provider-typebox
Creiamo lo schema con Typebox
import { Type } from '@fastify/type-provider-typebox'
import { FastifyPluginAsync } from "fastify"
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return 'this is an example'
})
fastify.post('/uppercase', {
schema: {
body: Type.Object({
text: Type.String(),
}),
response: {
200: Type.Object({
textResult: Type.String()
}),
}
}
}, async function (request, reply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
})
}
export default example;
Ora abbiamo creato uno schema che definisce che:
nel body accetta solo una proprietà 'text' di tipo stringa
come risposta restituisce un oggetto con solo una proprietà 'textResult' di tipo stringa
Lo schema fa si che:
Il cliente se nel body non passa esattamente un oggetto con una proprietà 'text' di tipo stringa riceverà un errore 404
Il server se non ritorna esattamente un oggetto con dentro la proprietà 'textResult' di tipo stringa, il cliente riceverà un errore 500
Ma c'è ancora un problema, lo schema è giusto ma Typescript ancora non compila perché request.body è ancora di tipo unknown.
Usiamo lo schema per generare i tipi per la rotta
import { Type, FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
const example: FastifyPluginAsyncTypebox = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return 'this is an example'
})
fastify.post('/uppercase', {
schema: {
body: Type.Object({
text: Type.String(),
}),
response: {
200: Type.Object({
textResult: Type.String()
}),
}
}
}, async function (request, reply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
})
}
export default example;
Ci basta solo sostituire il tipo:
- FastifyPluginAsync (tipo per il plugin importato direttamente da 'fastify')
Con il tipo:
- FastifyPluginAsyncTypebox (tipo per il plugin importato da '@fastify/type-provider-typebox')
Refactoring
Cosi funziona tutto, ma personalmente per maggiore leggibilità preferisco separare la funzione della rotta e lo schema dalla dichiarazione della rotta stessa.
import { Type, FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { FastifyReply, FastifyRequest } from 'fastify'
const example: FastifyPluginAsyncTypebox = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return 'this is an example'
})
const schemaPostUppercase = {
body: Type.Object({
text: Type.String(),
}),
response: {
200: Type.Object({
textResult: Type.String()
}),
}
}
async function postUppercase(request: FastifyRequest, reply: FastifyReply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
}
fastify.post('/uppercase', { schema: schemaPostUppercase }, postUppercase)
}
export default example;
In questo modo abbiamo separato lo schema nella variabile: schemaPostUppercase e l'handler della rotta nella funzione: postUppercase.
Però ora Typescript non è più in grado di tipizzare il body ed il ritorno della funzione e genera l'errore:
E da notare anche che il ritorno non è più tipizzato. Infatti Typescript ci permette di fare questo:
return { hello: text.toUpperCase()}
Ritornare la proprietà 'hello' dovrebbe generare un errore di compilazione Typescript, perché non rispetta lo schema che invece definisce che nella risposta ci sia solo la proprietà 'textResult'.
Questo perché la rotta non prende più i tipi dallo schema.
Tipizzazione dell'handler della rotta
import { Type, FastifyPluginAsyncTypebox, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { ContextConfigDefault, FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest, FastifySchema, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteGenericInterface } from 'fastify'
import { ResolveFastifyReplyReturnType } from 'fastify/types/type-provider'
type FastifyInstanceTypebox = FastifyInstance<
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
RawReplyDefaultExpression,
FastifyBaseLogger,
TypeBoxTypeProvider
>
type FastifyRequestTypebox<TSchema extends FastifySchema> = FastifyRequest<
RouteGenericInterface,
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
TSchema,
TypeBoxTypeProvider
>
type FastifyReplyTypebox<TSchema extends FastifySchema> = FastifyReply<
RawServerDefault,
RawRequestDefaultExpression,
RawReplyDefaultExpression,
RouteGenericInterface,
ContextConfigDefault,
TSchema,
TypeBoxTypeProvider
>
export type RouteHandlerTypebox<TSchema extends FastifySchema> = (
this: FastifyInstanceTypebox,
request: FastifyRequestTypebox<TSchema>,
reply: FastifyReplyTypebox<TSchema>
) => ResolveFastifyReplyReturnType<TypeBoxTypeProvider, TSchema, RouteGenericInterface>
const example: FastifyPluginAsyncTypebox = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return 'this is an example'
})
const schemaPostUppercase = {
body: Type.Object({
text: Type.String(),
}),
response: {
200: Type.Object({
textResult: Type.String()
}),
}
}
const postUppercase: RouteHandlerTypebox<typeof schemaPostUppercase> = async function (request, reply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
}
fastify.post('/uppercase', { schema: schemaPostUppercase }, postUppercase)
}
export default example;
Purtroppo per poter separare la dichiarazione della funzione 'postUppercase' dalla definizione della rotta bisogna creare a mano il suo tipo.
Per semplicità ho definito tutti i tipi all'interno dello stesso file, ma in un progetto reale generalmente si definiscono i tipi in un file separato ed importati nei file in cui vengono utilizzati.
Vediamo passo dopo passo questo processo:
- Abbiamo trasformato la funzione postUppercase da così:
async function postUppercase(request: FastifyRequest, reply: FastifyReply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
}
- A così:
const postUppercase: RouteHandlerTypebox<typeof schemaPostUppercase> = async function (request, reply) {
const body = request.body
const text = body.text
return { textResult: text.toUpperCase()}
}
postUppercase diventa da una funzione ad una variabile che contiene una funzione anonima, questa sintassi ci permette di definire i tipi per i parametri, il ritorno ed il contesto (this) della funzione con un solo tipo:
RouteHandlerTypebox<typeof schemaPostUppercase>
Vediamo la sua definizione:
export type RouteHandlerTypebox<TSchema extends FastifySchema> = (
this: FastifyInstanceTypebox,
request: FastifyRequestTypebox<TSchema>,
reply: FastifyReplyTypebox<TSchema>
) => ResolveFastifyReplyReturnType<TypeBoxTypeProvider, TSchema, RouteGenericInterface>
Nel tipo RouteHandlerTypebox, stiamo andando a definire i tipi per:
this: il contesto della funzione ovvero quando si vuole accedere all'oggetto 'fastify' tramite 'this'.
request: il primo parametro della funzione
reply: il secondo parametro della funzione
ritorno della funzione: ResolveFastifyReplyReturnType
Di fatto manualmente tipizzando con lo schema: request, reply ed il ritorno della funzione, che prendono il tipo generico: TSchema.
Manuel Salinardi