5 choses que vous n'avez jamais faites avec une spécification REST

Publié: 2022-03-11

La plupart des développeurs front-end et back-end ont déjà traité des spécifications REST et des API RESTful. Mais toutes les API RESTful ne sont pas créées égales. En fait, ils sont rarement RESTful du tout…

Qu'est- ce qu'une API RESTful ?

C'est un mythe.

Si vous pensez que votre projet a une API RESTful, vous vous trompez probablement. L'idée derrière une API RESTful est de se développer d'une manière qui respecte toutes les règles et limitations architecturales décrites dans la spécification REST. De manière réaliste, cependant, cela est largement impossible dans la pratique.

D'une part, REST contient trop de définitions floues et ambiguës. Par exemple, dans la pratique, certains termes des dictionnaires de méthodes HTTP et de codes d'état sont utilisés contrairement à leurs fins prévues, ou ne sont pas utilisés du tout.

D'un autre côté, le développement REST crée trop de limitations. Par exemple, l'utilisation des ressources atomiques n'est pas optimale pour les API du monde réel utilisées dans les applications mobiles. Le refus total de stockage de données entre les requêtes interdit essentiellement le mécanisme de "session utilisateur" que l'on voit un peu partout.

Mais attendez, ce n'est pas si mal !

Pourquoi avez-vous besoin d'une spécification d'API REST ?

Malgré ces inconvénients, avec une approche sensée, REST reste un concept étonnant pour créer de très bonnes API. Ces API peuvent être cohérentes et avoir une structure claire, une bonne documentation et une couverture de test unitaire élevée. Vous pouvez réaliser tout cela avec une spécification d'API de haute qualité .

Généralement, une spécification d'API REST est associée à sa documentation . Contrairement à une spécification (une description formelle de votre API), la documentation est censée être lisible par l'homme : par exemple, lue par les développeurs de l'application mobile ou Web qui utilise votre API.

Une description correcte de l'API ne consiste pas seulement à bien rédiger la documentation de l'API. Dans cet article, je veux partager des exemples de la façon dont vous pouvez :

  • Rendez vos tests unitaires plus simples et plus fiables ;
  • Configurer le prétraitement et la validation des entrées utilisateur ;
  • Automatisez la sérialisation et assurez la cohérence des réponses ; et même
  • Profitez des avantages de la saisie statique.

Mais d'abord, commençons par une introduction au monde des spécifications d'API.

OpenAPI

OpenAPI est actuellement le format le plus largement accepté pour les spécifications de l'API REST. La spécification est écrite dans un fichier unique au format JSON ou YAML composé de trois sections :

  1. Un en-tête avec le nom, la description et la version de l'API, ainsi que toute information supplémentaire.
  2. Descriptions de toutes les ressources, y compris les identifiants, les méthodes HTTP, tous les paramètres d'entrée, les codes de réponse et les types de données du corps, avec des liens vers les définitions.
  3. Toutes les définitions pouvant être utilisées pour l'entrée ou la sortie, au format JSON Schema (qui, oui, peut également être représentée en YAML.)

La structure d'OpenAPI présente deux inconvénients majeurs : elle est trop complexe et parfois redondante. Un petit projet peut avoir une spécification JSON de milliers de lignes. Maintenir ce fichier manuellement devient impossible. C'est une menace importante pour l'idée de maintenir la spécification à jour pendant le développement de l'API.

Il existe plusieurs éditeurs qui vous permettent de décrire une API et de produire une sortie OpenAPI. Les services supplémentaires et les solutions cloud basés sur eux incluent Swagger, Apiary, Stoplight, Restlet et bien d'autres.

Cependant, ces services n'étaient pas pratiques pour moi en raison de la complexité de l'édition rapide des spécifications et de leur alignement avec les modifications de code. De plus, la liste des fonctionnalités dépendait d'un service spécifique. Par exemple, créer des tests unitaires complets basés sur les outils d'un service cloud est quasiment impossible. La génération de code et les endpoints moqueurs, tout en semblant pratiques, s'avèrent la plupart du temps inutiles dans la pratique. Cela est principalement dû au fait que le comportement des points de terminaison dépend généralement de divers éléments, tels que les autorisations des utilisateurs et les paramètres d'entrée, qui peuvent être évidents pour un architecte d'API, mais ne sont pas faciles à générer automatiquement à partir d'une spécification OpenAPI.

Tinyspec

Dans cet article, j'utiliserai des exemples basés sur mon propre format de définition d'API REST, tinyspec . Les définitions consistent en de petits fichiers avec une syntaxe intuitive. Ils décrivent les points de terminaison et les modèles de données utilisés dans un projet. Les fichiers sont stockés à côté du code, fournissant une référence rapide et la possibilité d'être modifiés lors de l'écriture du code. Tinyspec est automatiquement compilé dans un format OpenAPI à part entière qui peut être immédiatement utilisé dans votre projet.

J'utiliserai également des exemples Node.js (Koa, Express) et Ruby on Rails, mais les pratiques que je vais démontrer sont applicables à la plupart des technologies, y compris Python, PHP et Java.

Là où la spécification de l'API bascule

Maintenant que nous avons un peu de contexte, nous pouvons explorer comment tirer le meilleur parti d'une API correctement spécifiée.

1. Tests unitaires de point de terminaison

Le développement piloté par le comportement (BDD) est idéal pour développer des API REST. Il est préférable d'écrire des tests unitaires non pas pour des classes, des modèles ou des contrôleurs distincts, mais pour des points de terminaison particuliers. Dans chaque test, vous émulez une vraie requête HTTP et vérifiez la réponse du serveur. Pour Node.js, il existe les packages supertest et chai-http pour émuler les requêtes, et pour Ruby on Rails, il y a airborne.

Supposons que nous ayons un schéma User et un point de terminaison GET /users qui renvoient tous les utilisateurs. Voici une syntaxe tinyspec qui décrit ceci :

 # user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}

Et voici comment nous écririons le test correspondant :

Node.js

 describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); expect(users[0].name).to.be('string'); expect(users[0].isAdmin).to.be('boolean'); expect(users[0].age).to.be.oneOf(['boolean', null]); }); });

Rubis sur rails

 describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect_json_types('users.*', { name: :string, isAdmin: :boolean, age: :integer_or_null, }) end end

Lorsque nous avons déjà la spécification qui décrit les réponses du serveur, nous pouvons simplifier le test et simplement vérifier si la réponse suit la spécification. Nous pouvons utiliser des modèles tinyspec, chacun pouvant être transformé en une spécification OpenAPI qui suit le format JSON Schema.

Tout objet littéral en JS (ou Hash en Ruby, dict en Python, tableau associatif en PHP et même Map en Java) peut être validé pour la conformité au schéma JSON. Il existe même des plugins appropriés pour tester les frameworks, par exemple jest-ajv (npm), chai-ajv-json-schema (npm) et json_matchers pour RSpec (rubygem).

Avant d'utiliser des schémas, importons-les dans le projet. Tout d'abord, générez le fichier openapi.json basé sur la spécification tinyspec (vous pouvez le faire automatiquement avant chaque test) :

 tinyspec -j -o openapi.json

Node.js

Vous pouvez maintenant utiliser le JSON généré dans le projet et en obtenir la clé de definitions . Cette clé contient tous les schémas JSON. Les schémas peuvent contenir des références croisées ( $ref ), donc si vous avez des schémas intégrés (par exemple, Blog {posts: Post[]} ), vous devez les déballer pour les utiliser dans la validation. Pour cela, nous utiliserons json-schema-deref-sync (npm).

 import deref from 'json-schema-deref-sync'; const spec = require('./openapi.json'); const schemas = deref(spec).definitions; describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); // Chai expect(users[0]).to.be.validWithSchema(schemas.User); // Jest expect(users[0]).toMatchSchema(schemas.User); }); });

Rubis sur rails

Le module json_matchers sait comment gérer $ref , mais nécessite des fichiers de schéma séparés à l'emplacement spécifié, vous devrez donc d'abord diviser le fichier swagger.json en plusieurs fichiers plus petits :

 # ./spec/support/json_schemas.rb require 'json' require 'json_matchers/rspec' JsonMatchers.schema_root = 'spec/schemas' # Fix for json_matchers single-file restriction file = File.read 'spec/schemas/openapi.json' swagger = JSON.parse(file, symbolize_names: true) swagger[:definitions].keys.each do |key| File.open("spec/schemas/#{key}.json", 'w') do |f| f.write(JSON.pretty_generate({ '$ref': "swagger.json#/definitions/#{key}" })) end end

Voici à quoi ressemblera le test :

 describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect(result[:users][0]).to match_json_schema('User') end end

Écrire des tests de cette façon est incroyablement pratique. Surtout si votre IDE prend en charge l'exécution de tests et le débogage (par exemple, WebStorm, RubyMine et Visual Studio). De cette façon, vous pouvez éviter d'utiliser d'autres logiciels, et l'ensemble du cycle de développement de l'API est limité à trois étapes :

  1. Conception de la spécification dans des fichiers tinyspec.
  2. Rédaction d'un ensemble complet de tests pour les points de terminaison ajoutés/modifiés.
  3. Implémentation du code qui satisfait aux tests.

2. Validation des données d'entrée

OpenAPI décrit non seulement le format de réponse, mais également les données d'entrée. Cela vous permet de valider les données envoyées par l'utilisateur lors de l'exécution et d'assurer des mises à jour cohérentes et sécurisées de la base de données.

Supposons que nous ayons la spécification suivante, qui décrit le correctif d'un enregistrement d'utilisateur et tous les champs disponibles pouvant être mis à jour :

 # user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}

Auparavant, nous avons exploré les plugins pour la validation in-test, mais pour les cas plus généraux, il existe les modules de validation ajv (npm) et json-schema (rubygem). Utilisons-les pour écrire un contrôleur avec validation :

Node.js (Koa)

Ceci est un exemple pour Koa, le successeur d'Express, mais le code Express équivalent serait similaire.

 import Router from 'koa-router'; import Ajv from 'ajv'; import { schemas } from './schemas'; const router = new Router(); // Standard resource update action in Koa. router.patch('/:id', async (ctx) => { const updateData = ctx.body.user; // Validation using JSON schema from API specification. await validate(schemas.UserUpdate, updateData); const user = await User.findById(ctx.params.id); await user.update(updateData); ctx.body = { success: true }; }); async function validate(schema, data) { const ajv = new Ajv(); if (!ajv.validate(schema, data)) { const err = new Error(); err.errors = ajv.errors; throw err; } }

Dans cet exemple, le serveur renvoie une réponse 500 Internal Server Error si l'entrée ne correspond pas à la spécification. Pour éviter cela, nous pouvons détecter l'erreur du validateur et former notre propre réponse qui contiendra des informations plus détaillées sur les champs spécifiques qui ont échoué à la validation, et suivre la spécification.

Ajoutons la définition de FieldsValidationError :

 # error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}

Et maintenant, listons-le comme l'une des réponses de point de terminaison possibles :

 # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError

Cette approche vous permet d'écrire des tests unitaires qui testent l'exactitude des scénarios d'erreur lorsque des données non valides proviennent du client.

3. Sérialisation du modèle

Presque tous les frameworks de serveur modernes utilisent le mappage objet-relationnel (ORM) d'une manière ou d'une autre. Cela signifie que la majorité des ressources utilisées par une API sont représentées par des modèles et leurs instances et collections.

Le processus de formation des représentations JSON pour ces entités à envoyer dans la réponse est appelé sérialisation .

Il existe un certain nombre de plugins pour effectuer la sérialisation : par exemple, sequelize-to-json (npm), act_as_api (rubygem) et jsonapi-rails (rubygem). Fondamentalement, ces plugins vous permettent de fournir la liste des champs pour un modèle spécifique qui doivent être inclus dans l'objet JSON, ainsi que des règles supplémentaires. Par exemple, vous pouvez renommer des champs et calculer leurs valeurs dynamiquement.

Cela devient plus difficile lorsque vous avez besoin de plusieurs représentations JSON différentes pour un modèle ou lorsque l'objet contient des entités imbriquées, des associations. Ensuite, vous commencez à avoir besoin de fonctionnalités telles que l'héritage, la réutilisation et la liaison de sérialiseur.

Différents modules fournissent différentes solutions, mais considérons ceci : la spécification peut-elle aider à nouveau ? Fondamentalement, toutes les informations sur les exigences des représentations JSON, toutes les combinaisons de champs possibles, y compris les entités intégrées, y sont déjà. Et cela signifie que nous pouvons écrire un seul sérialiseur automatisé.

Permettez-moi de vous présenter le petit module sequelize-serialize (npm), qui prend en charge cette opération pour les modèles Sequelize. Il accepte une instance de modèle ou un tableau et le schéma requis, puis le parcourt pour créer l'objet sérialisé. Il prend également en compte tous les champs obligatoires et utilise des schémas imbriqués pour leurs entités associées.

Donc, disons que nous devons renvoyer tous les utilisateurs avec des messages dans le blog, y compris les commentaires sur ces messages, à partir de l'API. Décrivons-le avec la spécification suivante :

 # models.tinyspec Comment {authorId: i, message} Post {topic, message, comments?: Comment[]} User {name, isAdmin: b, age?: i} UserWithPosts < User {posts: Post[]} # blogUsers.endpoints.tinyspec GET /blog/users => {users: UserWithPosts[]}

Nous pouvons maintenant construire la requête avec Sequelize et renvoyer l'objet sérialisé qui correspond exactement à la spécification décrite ci-dessus :

 import Router from 'koa-router'; import serialize from 'sequelize-serialize'; import { schemas } from './schemas'; const router = new Router(); router.get('/blog/users', async (ctx) => { const users = await User.findAll({ include: [{ association: User.posts, required: true, include: [Post.comments] }] }); ctx.body = serialize(users, schemas.UserWithPosts); });

C'est presque magique, n'est-ce pas ?

4. Dactylographie statique

Si vous êtes assez cool pour utiliser TypeScript ou Flow, vous vous êtes peut-être déjà demandé : « Et mes précieux types statiques ?! Avec les modules sw2dts ou swagger-to-flowtype, vous pouvez générer tous les types statiques nécessaires basés sur des schémas JSON et les utiliser dans des tests, des contrôleurs et des sérialiseurs.

 tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api

Maintenant, nous pouvons utiliser des types dans les contrôleurs :

 router.patch('/users/:id', async (ctx) => { // Specify type for request data object const userData: Api.UserUpdate = ctx.request.body.user; // Run spec validation await validate(schemas.UserUpdate, userData); // Query the database const user = await User.findById(ctx.params.id); await user.update(userData); // Return serialized result const serialized: Api.User = serialize(user, schemas.User); ctx.body = { user: serialized }; });

Et essais :

 it('Update user', async () => { // Static check for test input data. const updateData: Api.UserUpdate = { name: MODIFIED }; const res = await request.patch('/users/1', { user: updateData }); // Type helper for request response: const user: Api.User = res.body.user; expect(user).to.be.validWithSchema(schemas.User); expect(user).to.containSubset(updateData); });

Notez que les définitions de type générées peuvent être utilisées non seulement dans le projet API, mais également dans les projets d'application client pour décrire les types dans les fonctions qui fonctionnent avec l'API. (Les développeurs angulaires en seront particulièrement heureux.)

5. Casting des types de chaînes de requête

Si, pour une raison quelconque, votre API consomme des requêtes avec le type MIME application/x-www-form-urlencoded au lieu de application/json , le corps de la requête ressemblera à ceci :

 param1=value&param2=777&param3=false

Il en va de même pour les paramètres de requête (par exemple, dans les requêtes GET ). Dans ce cas, le serveur Web ne parviendra pas à reconnaître automatiquement les types : toutes les données seront au format chaîne, donc après l'analyse, vous obtiendrez cet objet :

 { param1: 'value', param2: '777', param3: 'false' }

Dans ce cas, la demande échouera à la validation du schéma, vous devez donc vérifier manuellement les formats des paramètres corrects et les convertir en types corrects.

Comme vous pouvez le deviner, vous pouvez le faire avec nos bons vieux schémas de la spécification. Disons que nous avons ce point de terminaison et le schéma suivant :

 # posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }

Voici à quoi ressemble la demande à ce point de terminaison :

 GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true

Écrivons la fonction castQuery pour convertir tous les paramètres en types requis :

 function castQuery(query, schema) { _.mapValues(query, (value, key) => { const { type } = schema.properties[key] || {}; if (!value || !type) { return value; } switch (type) { case 'integer': return parseInt(value, 10); case 'number': return parseFloat(value); case 'boolean': return value !== 'false'; default: return value; } }); }

Une implémentation plus complète avec prise en charge des schémas imbriqués, des tableaux et des types null est disponible dans le module cast-with-schema (npm). Utilisons-le maintenant dans notre code :

 router.get('/posts', async (ctx) => { // Cast parameters to expected types const query = castQuery(ctx.query, schemas.PostsQuery); // Run spec validation await validate(schemas.PostsQuery, query); // Query the database const posts = await Post.search(query); // Return serialized result ctx.body = { posts: serialize(posts, schemas.Post) }; });

Notez que trois des quatre lignes de code utilisent des schémas de spécification.

Les meilleures pratiques

Il existe un certain nombre de bonnes pratiques que nous pouvons suivre ici.

Utiliser des schémas de création et de modification séparés

Généralement, les schémas qui décrivent les réponses du serveur sont différents de ceux qui décrivent les entrées et sont utilisés pour créer et modifier des modèles. Par exemple, la liste des champs disponibles dans les requêtes POST et PATCH doit être strictement limitée, et PATCH a généralement tous les champs marqués comme facultatifs. Les schémas qui décrivent la réponse peuvent être plus libres.

Lorsque vous générez automatiquement des points de terminaison CRUDL, tinyspec utilise les suffixes New et Update à jour. Les schémas User* peuvent être définis de la manière suivante :

 User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}

Essayez de ne pas utiliser les mêmes schémas pour différents types d'action afin d'éviter les problèmes de sécurité accidentels dus à la réutilisation ou à l'héritage d'anciens schémas.

Suivre les conventions de dénomination des schémas

Le contenu des mêmes modèles peut varier pour différents paramètres. Utilisez les suffixes With* et For* dans les noms de schéma pour montrer la différence et le but. Dans tinyspec, les modèles peuvent également hériter les uns des autres. Par exemple:

 User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}

Les suffixes peuvent être variés et combinés. Leur nom doit encore refléter l'essence et rendre la documentation plus simple à lire.

Séparer les terminaux en fonction du type de client

Souvent, le même point de terminaison renvoie des données différentes en fonction du type de client ou du rôle de l'utilisateur qui a envoyé la demande. Par exemple, les points de terminaison GET /users et GET /messages peuvent être très différents pour les utilisateurs d'applications mobiles et les responsables de back office. La modification du nom du point de terminaison peut entraîner une surcharge.

Pour décrire plusieurs fois le même point de terminaison, vous pouvez ajouter son type entre parenthèses après le chemin. Cela facilite également l'utilisation des balises : vous divisez la documentation des points de terminaison en groupes, chacun étant destiné à un groupe de clients d'API spécifique. Par exemple:

 Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]

Outils de documentation de l'API REST

Après avoir obtenu la spécification au format Tinyspec ou OpenAPI, vous pouvez générer une belle documentation au format HTML et la publier. Cela rendra heureux les développeurs qui utilisent votre API, et cela vaut certainement mieux que de remplir à la main un modèle de documentation de l'API REST.

Outre les services cloud mentionnés précédemment, il existe des outils CLI qui convertissent OpenAPI 2.0 en HTML et PDF, qui peuvent être déployés sur n'importe quel hébergement statique. Voici quelques exemples:

  • bootprint-openapi (npm, utilisé par défaut dans tinyspec)
  • swagger2markup-cli (jar, il y a un exemple d'utilisation, sera utilisé dans tinyspec Cloud)
  • redoc-cli (npm)
  • widdershins (npm)

Avez-vous d'autres exemples ? Partagez-les dans les commentaires.

Malheureusement, malgré sa sortie il y a un an, OpenAPI 3.0 est toujours mal pris en charge et je n'ai pas réussi à trouver des exemples appropriés de documentation basée sur celle-ci à la fois dans les solutions cloud et dans les outils CLI. Pour la même raison, tinyspec ne prend pas encore en charge OpenAPI 3.0.

Publier sur GitHub

L'un des moyens les plus simples de publier la documentation est les pages GitHub. Activez simplement la prise en charge des pages statiques pour votre dossier /docs dans les paramètres du référentiel et stockez la documentation HTML dans ce dossier.

Hébergement de la documentation HTML de votre spécification REST depuis votre dossier /docs via GitHub Pages.

Vous pouvez ajouter la commande pour générer la documentation via tinyspec ou un autre outil CLI dans votre fichier scripts/package.json pour mettre à jour la documentation automatiquement après chaque commit :

 "scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }

Intégration continue

Vous pouvez ajouter la génération de documentation à votre cycle CI et la publier, par exemple, sur Amazon S3 sous différentes adresses en fonction de l'environnement ou de la version de l'API (comme /docs/2.0 , /docs/stable et /docs/staging .)

Nuage Tinyspec

Si vous aimez la syntaxe tinyspec, vous pouvez devenir l'un des premiers à adopter tinyspec.cloud. Nous prévoyons de construire un service cloud basé sur celui-ci et une CLI pour le déploiement automatisé de la documentation avec un large choix de modèles et la possibilité de développer des modèles personnalisés.

Spécification REST : un mythe merveilleux

Le développement d'API REST est probablement l'un des processus les plus agréables du développement de services Web et mobiles modernes. Il n'y a pas de navigateur, de système d'exploitation et de zoos de la taille d'un écran, et tout est entièrement sous votre contrôle, à portée de main.

Ce processus est encore facilité par la prise en charge de l'automatisation et des spécifications à jour. Une API utilisant les approches que j'ai décrites devient bien structurée, transparente et fiable.

En fin de compte, si nous créons un mythe, pourquoi ne pas en faire un mythe merveilleux ?

Connexes : ActiveResource.js ORM : création d'un SDK JavaScript puissant pour votre API JSON, rapide