Fastify: Type safe con i Type-Providers

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 .

Click here to read the article in english language

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:

  1. src/routes/root.ts

  2. 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