Contenu

Une API Design-first en Node.js avec Fastify

Présentation de l'approche Design-first par l'exemple

Une API Design-first en Node.js avec Fastify

Objectif

Dans cet article je vous propose de coder pas à pas une API Rest en Node.js et en mode “Design First”, c’est à dire que nous allons coder un contrat d’interface au format OpenAPI plutôt que de coder l’implémentation de notre API (les routes, les contrôleurs, la validation des entrants…).

On va s’appuyer sur la stack technique suivante :

Design First vs Code First

Premièrement il me faut vous parler de l’approche “Design First” (aussi appellée “API First”) qui nous intéresse ici. Pour ceci je ne saurai que vous conseiller d’aller lire l’article suivant qui explique et compare les 2 approches :

🔗 https://swagger.io/blog/api-design/design-first-or-code-first-api-development/

design-first-vs-code-first

En gros il faut bien cerner vos besoins concernant l’API que vous voulez coder.

Ce que j’en retire personnellement comme conclusions :

  • C’est que l’approche “Design First” fluidifie la collaboration avec les utilisateurs de votre API (autre équipe de dev / client) dans le sens où les ajustements se font directement sur le contrat d’interface plutôt que dans le code de votre application, ce qui rend plus simples et plus rapides les évolutions à apporter à l’API.
  • De plus cette approche permet de se concentrer uniquement sur le design de l’API sans avoir de biais liés à l’implémentation, et donc de s’assurer qu’elle conviendra parfaitement à vos clients.

Par contre il se peut que vous trouviez fastidieux d’adapter le code de l’application au fur et à mesure des évolutions du contrat d’interface.

Mais pas de panique il existe des outils pour vous aider à mettre en pratique cette approche “Design First” 😃

OpenAPI

Ok c’est parti on commence donc par le contrat d’interface.

Open API Initiative

La spécification qui s’est imposée pour décrire des contrats d’interface est la norme OpenAPI Specification. C’est la suite de la la spécification Swagger, proposée par SmartBear Software, depuis 2015 elle est sous l’égide de la OpenAPI Initiative.

On peut la décrire comme suit :

La spécification OpenAPI (OAS) définit une interface standard indépendante du langage pour les API RESTful qui permet aux humains et aux ordinateurs de découvrir et de comprendre les capacités du service sans accéder au code source.

➡️ Concrètement, elle prend la forme d’un document JSON ou YAML.

Si vous êtes un fou furieux vous pouvez ouvrir votre IDE favori et implémenter directement le code de votre contrat d’interface… Sinon il y a plusieurs outils qui vont vous faciliter cette étape.

Il y a tout d’abord le classique Swagger Editor dispo en ligne qui est bien pratique avec son volet de visualisation du rendu dans une instance Swagger UI. Par contre là encore il vous faut coder à la main le contrat d’interface.

Pour ceux qui ne maitrisent pas encore la syntaxe OpenAPI il existe des outils plus complets qui permettent de switcher entre le code, et un mode de saisie via une IHM.

Personnellement j’ai testé et vous recommande Stoplight Studio.

stoplight-studio

Démo

Je vous propose d’illustrer tout ça avec un petit projet basique d’API en Node.JS

Etape 1 : Initier un serveur Fastify

Tout d’abord il faut installer Fastify

Avec npm :

1
npm i fastify

Avec yarn :

1
yarn add fastify

Je vous propose d’implémenter le serveur Fastify en Typescript, dans un fichier src/app.ts comme suit :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const server = Fastify({
    logger: true
})

// Run the server!
server.listen({port: 3000, host: '0.0.0.0'}, function (err, address) {
    if (err) {
        server.log.error(err)
        process.exit(1)
    }
    server.log?.info(`Server listening at ${address}`)
})

Ensuite avant de lancer le serveur, on va ajouter un peu de configuration en rapport avec notre cible qui est la suivante :

Les deux scripts suivants permettent de builder le code Javascript à partir de sources Typescript, et de démarrer le serveur Fastify via ts-node. Ils sont à ajouter dans le fichier package.json :

1
2
3
4
  "scripts": {
    "start": "npx ts-node src/app.ts",
    "build": "tsc"
  }

Pour packager correctement le module, il est nécessaire d’installer également les dépendances suivantes :

1
npm i --save-dev typescript @types/node ts-node 

puis d’ajouter également la configuration suivante dans le fichier package.json :

1
2
3
4
5
6
7
{
  "name": "petstore-design-first",
  "version": "1.0.0",
  "description": "Sample project demonstrating API design-first approach with Fastify",
  "main": "src/app.ts",
  "type": "module"
}

Et enfin on ajoute le fichier de configuration Typescript tsconfig.json suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["ES2022"],
    "allowJs": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "outDir": "dist",
    "module": "ES2022",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": ["src/**/*.ts"],
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"
  }
}

🚀 Désormais le serveur Fastify se lance correctement via la commande suivante :

1
2
3
4
5
6
npm run start

> petstore-design-first@1.0.0 start
> npx ts-node src/app.ts

{"level":30,"time":1669382824321,"pid":29011,"hostname":"hostname","msg":"Server listening at http://0.0.0.0:3000"}
dabbing

Plus d’infos pour bien démarrer avec Fastify ici :

🔗 https://www.fastify.io/docs/latest/Guides/Getting-Started/

🔗 https://www.fastify.io/docs/latest/Reference/TypeScript/

Etape 2 : Implémenter le contrat d’interface

Maintenant qu’on a un serveur web qui tourne, on va implémenter notre API…

Mais avec l’approche “Design First” bien entendu 😁

J’ai repris le contrat d’interface proposé sur le Github de fastify-openapi-glue que j’ai un peu allegé :

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
{
  "openapi": "3.0.2",
  "info": {
    "title": "Swagger Petstore - OpenAPI 3.0",
    "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification.  You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)",
    "termsOfService": "http://swagger.io/terms/",
    "contact": {
      "email": "apiteam@swagger.io"
    },
    "license": {
      "name": "Apache 2.0",
      "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
    },
    "version": "1.0.17"
  },
  "externalDocs": {
    "description": "Find out more about Swagger",
    "url": "http://swagger.io"
  },
  "servers": [
    {
      "url": ""
    }
  ],
  "tags": [
    {
      "name": "pet",
      "description": "Everything about your Pets",
      "externalDocs": {
        "description": "Find out more",
        "url": "http://swagger.io"
      }
    },
    {
      "name": "store",
      "description": "Access to Petstore orders",
      "externalDocs": {
        "description": "Find out more about our store",
        "url": "http://swagger.io"
      }
    }
  ],
  "paths": {
    "/pet": {
      "post": {
        "tags": [
          "pet"
        ],
        "summary": "Add a new pet to the store",
        "description": "Add a new pet to the store",
        "operationId": "addPet",
        "requestBody": {
          "description": "Create a new pet in the store",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Pet"
              },
              "examples": {
                "post-labrador": {
                  "value": {
                    "id": 3,
                    "name": "Golden Retriever",
                    "category": {
                      "id": 1,
                      "name": "Dog"
                    }
                  }
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "400": {
            "description": "Invalid input"
          },
          "500": {
            "description": "Internal Server Error"
          }
        }
      }
    },
    "/pet/{petId}": {
      "get": {
        "tags": [
          "pet"
        ],
        "summary": "Find pet by ID",
        "description": "Returns a single pet",
        "operationId": "getPetById",
        "parameters": [
          {
            "name": "petId",
            "in": "path",
            "description": "ID of pet to return",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pet"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ID supplied"
          },
          "404": {
            "description": "Pet not found"
          }
        }
      },
      "delete": {
        "tags": [
          "pet"
        ],
        "summary": "Deletes a pet",
        "description": "Deletes a pet",
        "operationId": "deletePet",
        "parameters": [
          {
            "name": "petId",
            "in": "path",
            "description": "Pet id to delete",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Invalid pet value"
          },
          "500": {
            "description": "Internal Server Error"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Category": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "name": {
            "type": "string"
          }
        },
        "description": "Model of a Pet Category"
      },
      "Pet": {
        "type": "object",
        "description": "Model of a Pet",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "name": {
            "type": "string"
          },
          "category": {
            "$ref": "#/components/schemas/Category"
          }
        },
        "required": [
          "name"
        ]
      }
    }
  }
}

Vous pouvez éditer ce document via les outils cités préalablement.

Dans ce contrat d’interface on retrouve de la validation de donnée via JSON Schema.

Vous noterez aussi les champs operationId sur chaque endpoint exposé. Ces champs seront utilisés pour pointer vers les bonnes méthodes de la classe où seront codées les implémentations fonctionnelles.

Pour implémenter les endpoints décrits dans cette spécification nous allons ajouter à notre application le plugin Fastify OpenApi Glue :

1
npm install fastify-openapi-glue --save

Ce plugin permet de générer toute la configuration Fastify des différents endpoints, ainsi que les contrôles de validation des données entrantes. Ce qu’il reste à coder est l’implémentation du service, à savoir uniquement le code métier de l’API 😎

La classe suivante déclare des méthodes nommées en reprenant la valeur des champs operationId des différents endpoints du contrat d’interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {FastifyReply, FastifyRequest} from "fastify";

class RouteHandler {

    addPet = async (req: FastifyRequest, reply: FastifyReply) => {
        console.log("add new pet : " + req.body)
        return reply.send("post ok")
    }

    getPetById = async (req: FastifyRequest, reply: FastifyReply) => {
        const params: any = req.params
        console.log("get pet from id : " + params.petId)
        return reply.send("get ok")
    }

    deletePet = async (req: FastifyRequest, reply: FastifyReply) => {
        const params: any = req.params
        console.log("delete pet from id : " + params.petId)
        return reply.send("delete ok")
    }
}

export default RouteHandler

Dernière étape pour faire fonctionner ce plugin, l’enregister auprès du serveur Fastify, et lui indiquer où se trouve la spec OpenAPI :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import {dirname, join} from 'path'
import openapiGlue from 'fastify-openapi-glue'
import {fileURLToPath} from "url";

const dirName = dirname(fileURLToPath(import.meta.url))
const glueOptions = {
    specification: join(dirName, './petstore.json'),
    service: new RouteHandler()
}
server.register(openapiGlue, glueOptions)

Vous pouvez tester les différentes routes pour vérifier que vous pointez bien vers les bonnes méthodes :

1
2
curl -X 'GET' 'http://localhost:3000/pet/1' -H 'accept: application/json'
get ok%

Là où c’est intéressant à cette étape, c’est que vous n’avez encore rien codé de votre logique métier, et que les contrôles de validation sont déjà opérationnels.

Vous pouvez le constater via la reqûete suivante par exemple :

1
2
3
4
❯ curl -X 'POST' 'http://localhost:3000/pet' -H 'Content-Type: application/json' \
  -d '{ "id": 3, "category": { "id": 1, "name": "Dog" } }'

{"statusCode":400,"error":"Bad Request","message":"body must have required property 'name'"}%

Etape 3 : Exposer le contrat d’interface sur l’API et ajouter l’interface Swagger-UI

Une idée intéressante désormais est d’exposer le contrat d’interface au format OpenAPI dans votre API elle-même.

Cela est très pratique pour les utilisateurs de votre API, en effet à chaque redéploiement de votre API, la spec qui correspond au code est accessible au même endroit.

Cette bonne pratique simplifie la collaboration avec vos utilisateurs.

Autre bonne idée, fournir une interface web Swagger-UI basée sur le contrat d’interface et simplifiant encore plus l’utilisation de votre API. C’est vrai que c’est plus simple d’utilisation qu’une collection Postman ou d’écrire des requêtes Curl.

L’installation des plugins Fastify Swagger et Fastify Swagger UI permet de bénéficier de ces fonctionnalités :

1
npm install @fastify/swagger @fastify/swagger-ui --save

Ne pas oublier d’activer ces nouveaux plugins au serveur Fastify :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import swaggerUi, { FastifySwaggerUiOptions } from '@fastify/swagger-ui'
import swagger, {FastifyStaticSwaggerOptions} from '@fastify/swagger'

const fastifySwaggerUiOptions: FastifySwaggerUiOptions = {
    routePrefix: `/documentation`
}

const fastifySwaggerOptions: FastifyStaticSwaggerOptions = {
    mode: 'static',
    specification: {
        path: join(dirName, './petstore.json'),
        baseDir: ''
    }
}

server.register(swagger, fastifySwaggerOptions)
server.register(swaggerUi, fastifySwaggerUiOptions)

Désormais, vous pouvez vous rendre à la page http://localhost:3000/documentation pour accèder à l’interface Swagger-UI et ainsi tester vos différents endpoints facilement 😎


swagger-ui

Conclusion

Voilà vous pouvez désormais continuer d’avancer sur 2 axes en parallèle :

  • Implémenter la logique métier de votre API dans la classe RouteHandler.ts
  • Itérer avec vos utilisateurs sur la spec OpenAPI :
    • Designer des nouveaux endpoints
    • Documenter un max les différents paths et modèles de donnée, en décrivant de manière la plus précise possible les modèles de donnée (format, utilisation d’énumérations, des champs description
    • Ajouter des exemples de requêtes sur chaque Path

Le projet complet présenté dans cet article est à retrouver ici : https://gitlab.com/gasouch/petstore-design-first

À propos de l'auteur : Mathieu Souchet

Développeur Full-Stack depuis 2007 (à forte teneur en Java), j’ai rejoint l’aventure Max en janvier 2017. Mon parcours m’a amené à collaborer au plus près de mes clients, leur apportant expertise technique, méthodologique et sens du service.
Touche-à-tout, passionné de technique, je m’épanouis en découvrant de nouvelles technos dans un contexte Agile et dans la bonne humeur.