5 cose che non hai mai fatto con una specifica REST
Pubblicato: 2022-03-11La maggior parte degli sviluppatori front-end e back-end ha già affrontato le specifiche REST e le API RESTful. Ma non tutte le API RESTful sono create uguali. In effetti, raramente sono RESTful...
Che cos'è un'API RESTful?
È un mito.
Se pensi che il tuo progetto abbia un'API RESTful, molto probabilmente ti sbagli. L'idea alla base di un'API RESTful è di svilupparsi in un modo che segua tutte le regole e le limitazioni dell'architettura descritte nella specifica REST. Realisticamente, tuttavia, questo è in gran parte impossibile nella pratica.
Da un lato, REST contiene troppe definizioni sfocate e ambigue. Ad esempio, in pratica, alcuni termini del metodo HTTP e dei dizionari del codice di stato vengono utilizzati contrariamente agli scopi previsti o non vengono utilizzati affatto.
D'altra parte, lo sviluppo REST crea troppe limitazioni. Ad esempio, l'uso delle risorse atomiche non è ottimale per le API del mondo reale utilizzate nelle applicazioni mobili. Il rifiuto completo dell'archiviazione dei dati tra le richieste essenzialmente vieta il meccanismo della "sessione utente" visto praticamente ovunque.
Ma aspetta, non è così male!
A cosa serve una specifica API REST?
Nonostante questi inconvenienti, con un approccio ragionevole, REST è ancora un concetto straordinario per la creazione di API davvero eccezionali. Queste API possono essere coerenti e avere una struttura chiara, una buona documentazione e un'elevata copertura dei test unitari. Puoi ottenere tutto questo con una specifica API di alta qualità.
Di solito una specifica API REST è associata alla relativa documentazione . A differenza di una specifica, una descrizione formale della tua API, la documentazione deve essere leggibile dall'uomo: ad esempio, letta dagli sviluppatori dell'applicazione mobile o web che utilizza la tua API.
Una descrizione API corretta non riguarda solo la scrittura corretta della documentazione API. In questo articolo voglio condividere esempi di come puoi:
- Rendi i tuoi unit test più semplici e affidabili;
- Impostare la preelaborazione e la convalida dell'input dell'utente;
- Automatizzare la serializzazione e garantire la coerenza della risposta; e persino
- Goditi i vantaggi della digitazione statica.
Ma prima, iniziamo con un'introduzione al mondo delle specifiche API.
OpenAPI
OpenAPI è attualmente il formato più ampiamente accettato per le specifiche API REST. La specifica è scritta in un unico file in formato JSON o YAML composto da tre sezioni:
- Un'intestazione con il nome, la descrizione e la versione dell'API, nonché qualsiasi informazione aggiuntiva.
- Descrizioni di tutte le risorse, inclusi identificatori, metodi HTTP, tutti i parametri di input, codici di risposta e tipi di dati del corpo, con collegamenti alle definizioni.
- Tutte le definizioni che possono essere utilizzate per l'input o l'output, in formato JSON Schema (che, sì, può essere rappresentato anche in YAML).
La struttura di OpenAPI ha due svantaggi significativi: è troppo complessa e talvolta ridondante. Un piccolo progetto può avere una specifica JSON di migliaia di righe. Mantenere questo file manualmente diventa impossibile. Questa è una minaccia significativa all'idea di mantenere le specifiche aggiornate durante lo sviluppo dell'API.
Esistono più editor che consentono di descrivere un'API e produrre output OpenAPI. Servizi aggiuntivi e soluzioni cloud basate su di essi includono Swagger, Apiary, Stoplight, Restlet e molti altri.
Tuttavia, questi servizi erano scomodi per me a causa della complessità della modifica rapida delle specifiche e dell'allineamento con le modifiche al codice. Inoltre, l'elenco delle funzionalità dipendeva da un servizio specifico. Ad esempio, la creazione di unit test completi basati sugli strumenti di un servizio cloud è quasi impossibile. La generazione di codice e gli endpoint beffardi, sebbene sembrino pratici, si rivelano per lo più inutili nella pratica. Ciò è principalmente dovuto al fatto che il comportamento dell'endpoint di solito dipende da varie cose come le autorizzazioni utente e i parametri di input, che possono essere ovvi per un architetto API ma non sono facili da generare automaticamente da una specifica OpenAPI.
Tinyspec
In questo articolo, utilizzerò esempi basati sul mio formato di definizione API REST, tinyspec . Le definizioni sono costituite da piccoli file con una sintassi intuitiva. Descrivono gli endpoint e i modelli di dati utilizzati in un progetto. I file vengono archiviati accanto al codice, fornendo un riferimento rapido e la possibilità di essere modificati durante la scrittura del codice. Tinyspec viene automaticamente compilato in un formato OpenAPI completo che può essere immediatamente utilizzato nel tuo progetto.
Userò anche esempi di Node.js (Koa, Express) e Ruby on Rails, ma le pratiche che dimostrerò sono applicabili alla maggior parte delle tecnologie, inclusi Python, PHP e Java.
Laddove le specifiche API oscillano
Ora che abbiamo un po' di background, possiamo esplorare come ottenere il massimo da un'API specificata correttamente.
1. Test unitari degli endpoint
Lo sviluppo basato sul comportamento (BDD) è l'ideale per lo sviluppo di API REST. È meglio scrivere unit test non per classi, modelli o controller separati, ma per endpoint particolari. In ogni test emuli una vera richiesta HTTP e verifichi la risposta del server. Per Node.js ci sono i pacchetti supertest e chai-http per l'emulazione delle richieste, e per Ruby on Rails c'è airborne.
Supponiamo di avere uno schema User
e un endpoint GET /users
che restituisce tutti gli utenti. Ecco una sintassi tinyspec che descrive questo:
# user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}
Ed ecco come scriveremmo il test corrispondente:
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]); }); });
Rubino su rotaie
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
Quando abbiamo già la specifica che descrive le risposte del server, possiamo semplificare il test e controllare semplicemente se la risposta segue la specifica. Possiamo utilizzare modelli tinyspec, ognuno dei quali può essere trasformato in una specifica OpenAPI che segue il formato JSON Schema.
Qualsiasi oggetto letterale in JS (o Hash
in Ruby, dict
in Python, array associativo in PHP e persino Map
in Java) può essere convalidato per la conformità allo schema JSON. Esistono anche plugin appropriati per testare i framework, ad esempio jest-ajv (npm), chai-ajv-json-schema (npm) e json_matchers per RSpec (rubygem).
Prima di utilizzare gli schemi, importiamoli nel progetto. Innanzitutto, genera il file openapi.json
in base alle specifiche tinyspec (puoi farlo automaticamente prima di ogni esecuzione di test):
tinyspec -j -o openapi.json
Node.js
Ora puoi utilizzare il JSON generato nel progetto e ottenere la chiave delle definitions
da esso. Questa chiave contiene tutti gli schemi JSON. Gli schemi possono contenere riferimenti incrociati ( $ref
), quindi se hai degli schemi incorporati (ad esempio Blog {posts: Post[]}
), devi scartarli per utilizzarli nella convalida. Per questo, useremo 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); }); });
Rubino su rotaie
Il modulo json_matchers
sa come gestire i riferimenti $ref
, ma richiede file di schema separati nella posizione specificata, quindi dovrai prima dividere il file swagger.json
in più file più piccoli:
# ./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
Ecco come sarà il 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
Scrivere i test in questo modo è incredibilmente conveniente. Soprattutto se il tuo IDE supporta l'esecuzione di test e debug (ad esempio, WebStorm, RubyMine e Visual Studio). In questo modo puoi evitare di utilizzare altri software e l'intero ciclo di sviluppo dell'API è limitato a tre passaggi:
- Progettazione della specifica in file tinyspec.
- Scrivere un set completo di test per gli endpoint aggiunti/modificati.
- Implementazione del codice che soddisfa i test.
2. Convalida dei dati di input
OpenAPI descrive non solo il formato della risposta, ma anche i dati di input. Ciò consente di convalidare i dati inviati dall'utente in fase di esecuzione e garantire aggiornamenti del database coerenti e sicuri.
Diciamo che abbiamo la seguente specifica, che descrive l'applicazione di patch a un record utente e tutti i campi disponibili che possono essere aggiornati:
# user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}
In precedenza, abbiamo esplorato i plugin per la convalida in-test, ma per casi più generali ci sono i moduli di convalida ajv (npm) e json-schema (rubygem). Usiamoli per scrivere un controller con validazione:
Node.js (Koa)
Questo è un esempio per Koa, il successore di Express, ma il codice Express equivalente sarebbe simile.
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; } }
In questo esempio, il server restituisce una risposta 500 Internal Server Error
se l'input non corrisponde alla specifica. Per evitare ciò, possiamo rilevare l'errore del validatore e formare la nostra risposta che conterrà informazioni più dettagliate su campi specifici che non hanno superato la convalida e seguire la specifica.
Aggiungiamo la definizione per FieldsValidationError
:
# error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}
E ora elenchiamolo come una delle possibili risposte dell'endpoint:
# users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError
Questo approccio consente di scrivere unit test che verificano la correttezza degli scenari di errore quando dal client provengono dati non validi.
3. Serializzazione del modello
Quasi tutti i moderni framework di server utilizzano la mappatura relazionale a oggetti (ORM) in un modo o nell'altro. Ciò significa che la maggior parte delle risorse utilizzate da un'API sono rappresentate da modelli e relative istanze e raccolte.

Il processo di formazione delle rappresentazioni JSON per queste entità da inviare nella risposta è chiamato serializzazione .
Esistono numerosi plugin per eseguire la serializzazione: ad esempio, sequelize-to-json (npm), act_as_api (rubygem) e jsonapi-rails (rubygem). Fondamentalmente, questi plug-in consentono di fornire l'elenco dei campi per un modello specifico che deve essere incluso nell'oggetto JSON, nonché regole aggiuntive. Ad esempio, puoi rinominare i campi e calcolarne i valori in modo dinamico.
Diventa più difficile quando sono necessarie diverse rappresentazioni JSON per un modello o quando l'oggetto contiene entità nidificate, associazioni. Quindi inizi a aver bisogno di funzionalità come l'ereditarietà, il riutilizzo e il collegamento del serializzatore.
Moduli diversi forniscono soluzioni diverse, ma consideriamo questo: le specifiche possono essere di nuovo utili? Fondamentalmente tutte le informazioni sui requisiti per le rappresentazioni JSON, tutte le possibili combinazioni di campi, comprese le entità incorporate, sono già presenti. E questo significa che possiamo scrivere un singolo serializzatore automatizzato.
Consentitemi di presentare il piccolo modulo sequelize-serialize (npm), che supporta questa operazione per i modelli Sequelize. Accetta un'istanza del modello o una matrice e lo schema richiesto, quindi esegue l'iterazione per creare l'oggetto serializzato. Tiene inoltre conto di tutti i campi obbligatori e utilizza schemi nidificati per le entità associate.
Quindi, supponiamo di dover restituire tutti gli utenti con post nel blog, inclusi i commenti a questi post, dall'API. Descriviamolo con la seguente specifica:
# 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[]}
Ora possiamo costruire la richiesta con Sequelize e restituire l'oggetto serializzato che corrisponde esattamente alla specifica sopra descritta:
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); });
Questo è quasi magico, vero?
4. Digitazione statica
Se sei abbastanza bravo da usare TypeScript o Flow, potresti aver già chiesto: "E i miei preziosi tipi statici?!" Con i moduli sw2dts o swagger-to-flowtype puoi generare tutti i tipi statici necessari basati su schemi JSON e usarli in test, controller e serializzatori.
tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api
Ora possiamo usare i tipi nei controller:
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 }; });
E prove:
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); });
Si noti che le definizioni di tipo generate possono essere utilizzate non solo nel progetto API, ma anche nei progetti di applicazioni client per descrivere i tipi nelle funzioni che funzionano con l'API. (Gli sviluppatori Angular saranno particolarmente felici di questo.)
5. Casting di tipi di stringhe di query
Se la tua API per qualche motivo consuma richieste con il tipo MIME application/x-www-form-urlencoded
invece di application/json
, il corpo della richiesta sarà simile al seguente:
param1=value¶m2=777¶m3=false
Lo stesso vale per i parametri di query (ad esempio, nelle richieste GET
). In questo caso, il server web non riconoscerà automaticamente i tipi: tutti i dati saranno in formato stringa, quindi dopo l'analisi otterrai questo oggetto:
{ param1: 'value', param2: '777', param3: 'false' }
In questo caso, la richiesta non riuscirà a convalidare lo schema, quindi è necessario verificare manualmente i formati dei parametri corretti e trasmetterli ai tipi corretti.
Come puoi immaginare, puoi farlo con i nostri buoni vecchi schemi dalle specifiche. Supponiamo di avere questo endpoint e il seguente schema:
# posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }
Ecco come appare la richiesta a questo endpoint:
GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true
Scriviamo la funzione castQuery
per eseguire il cast di tutti i parametri sui tipi richiesti:
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; } }); }
Un'implementazione più completa con supporto per schemi nidificati, matrici e tipi null
è disponibile nel modulo cast-with-schema (npm). Ora usiamolo nel nostro codice:
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) }; });
Si noti che tre delle quattro righe di codice utilizzano schemi di specifica.
Migliori pratiche
Ci sono una serie di migliori pratiche che possiamo seguire qui.
Usa Crea e Modifica schemi separati
Di solito gli schemi che descrivono le risposte del server sono diversi da quelli che descrivono gli input e vengono utilizzati per creare e modificare modelli. Ad esempio, l'elenco dei campi disponibili nelle richieste POST
e PATCH
deve essere strettamente limitato e PATCH
di solito ha tutti i campi contrassegnati come facoltativi. Gli schemi che descrivono la risposta possono essere più liberi.
Quando generi automaticamente gli endpoint CRUDL, tinyspec utilizza i postfissi New
e Update
. Gli schemi User*
possono essere definiti nel modo seguente:
User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}
Cerca di non utilizzare gli stessi schemi per tipi di azione diversi per evitare problemi di sicurezza accidentali dovuti al riutilizzo o all'ereditarietà di schemi precedenti.
Segui le convenzioni di denominazione degli schemi
Il contenuto degli stessi modelli può variare per endpoint diversi. Utilizzare i postfissi With*
e For*
nei nomi degli schemi per mostrare la differenza e lo scopo. In tinyspec, i modelli possono anche ereditarsi l'uno dall'altro. Per esempio:
User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}
I postfissi possono essere variati e combinati. Il loro nome deve comunque riflettere l'essenza e rendere la documentazione più semplice da leggere.
Separare gli endpoint in base al tipo di client
Spesso lo stesso endpoint restituisce dati diversi in base al tipo di client o al ruolo dell'utente che ha inviato la richiesta. Ad esempio, gli endpoint GET /users
e GET /messages
possono essere significativamente diversi per gli utenti delle applicazioni mobili e per i responsabili del back office. La modifica del nome dell'endpoint può essere un sovraccarico.
Per descrivere lo stesso endpoint più volte puoi aggiungere il suo tipo tra parentesi dopo il percorso. Ciò semplifica anche l'utilizzo dei tag: dividi la documentazione dell'endpoint in gruppi, ognuno dei quali è destinato a un gruppo di client API specifico. Per esempio:
Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]
Strumenti di documentazione dell'API REST
Dopo aver ottenuto la specifica in formato tinyspec o OpenAPI, è possibile generare documentazione di bell'aspetto in formato HTML e pubblicarla. Ciò renderà felici gli sviluppatori che utilizzano la tua API e sicuramente batte la compilazione manuale di un modello di documentazione dell'API REST.
Oltre ai servizi cloud menzionati in precedenza, esistono strumenti CLI che convertono OpenAPI 2.0 in HTML e PDF, che possono essere distribuiti su qualsiasi hosting statico. Ecco alcuni esempi:
- bootprint-openapi (npm, usato per impostazione predefinita in tinyspec)
- swagger2markup-cli (jar, c'è un esempio di utilizzo, sarà usato in tinyspec Cloud)
- redoc-cli (npm)
- stinchi larghi (npm)
Hai più esempi? Condividili nei commenti.
Purtroppo, nonostante sia stato rilasciato un anno fa, OpenAPI 3.0 è ancora scarsamente supportato e non sono riuscito a trovare esempi adeguati di documentazione basata su di esso sia nelle soluzioni cloud che negli strumenti CLI. Per lo stesso motivo, tinyspec non supporta ancora OpenAPI 3.0.
Pubblicazione su GitHub
Uno dei modi più semplici per pubblicare la documentazione è GitHub Pages. Abilita semplicemente il supporto per le pagine statiche per la tua cartella /docs
nelle impostazioni del repository e archivia la documentazione HTML in questa cartella.
Puoi aggiungere il comando per generare documentazione tramite tinyspec o un diverso strumento CLI nel tuo file scripts/package.json
per aggiornare automaticamente la documentazione dopo ogni commit:
"scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }
Integrazione continua
Puoi aggiungere la generazione della documentazione al ciclo CI e pubblicarla, ad esempio, su Amazon S3 con indirizzi diversi a seconda dell'ambiente o della versione dell'API (come /docs/2.0
, /docs/stable
e /docs/staging
.)
Tinypec Cloud
Se ti piace la sintassi tinyspec, puoi diventare uno dei primi ad adottare tinyspec.cloud. Abbiamo in programma di costruire un servizio cloud basato su di esso e una CLI per la distribuzione automatizzata della documentazione con un'ampia scelta di modelli e la possibilità di sviluppare modelli personalizzati.
Specifiche REST: un mito meraviglioso
Lo sviluppo dell'API REST è probabilmente uno dei processi più piacevoli nello sviluppo di servizi Web e mobili moderni. Non ci sono browser, sistema operativo e zoo delle dimensioni dello schermo e tutto è completamente sotto il tuo controllo, a portata di mano.
Questo processo è reso ancora più semplice dal supporto per l'automazione e le specifiche aggiornate. Un'API che utilizza gli approcci che ho descritto diventa ben strutturata, trasparente e affidabile.
La conclusione è che, se stiamo creando un mito, perché non trasformarlo in un mito meraviglioso?