5 lucruri pe care nu le-ați făcut niciodată cu o specificație REST
Publicat: 2022-03-11Majoritatea dezvoltatorilor front-end și back-end au mai tratat specificațiile REST și API-urile RESTful. Dar nu toate API-urile RESTful sunt create egale. De fapt, rar sunt deloc RESTful...
Ce este un API RESTful?
Este un mit.
Dacă credeți că proiectul dvs. are un API RESTful, cel mai probabil vă înșelați. Ideea din spatele unui API RESTful este să se dezvolte într-un mod care să respecte toate regulile și limitările arhitecturale descrise în specificația REST. În mod realist, totuși, acest lucru este în mare măsură imposibil în practică.
Pe de o parte, REST conține prea multe definiții neclare și ambigue. De exemplu, în practică, unii termeni din metoda HTTP și dicționarele de coduri de stare sunt utilizați contrar scopurilor propuse sau nu sunt utilizați deloc.
Pe de altă parte, dezvoltarea REST creează prea multe limitări. De exemplu, utilizarea resurselor atomice este suboptimă pentru API-urile din lumea reală care sunt utilizate în aplicațiile mobile. Refuzarea completă a stocării datelor între solicitări interzice, în esență, mecanismul „sesiune utilizator” văzut aproape peste tot.
Dar stai, nu e chiar așa de rău!
Pentru ce aveți nevoie de o specificație API REST?
În ciuda acestor dezavantaje, cu o abordare sensibilă, REST este încă un concept uimitor pentru crearea de API-uri cu adevărat grozave. Aceste API-uri pot fi consistente și au o structură clară, o documentație bună și o acoperire mare a testelor unitare. Puteți realiza toate acestea cu o specificație API de înaltă calitate.
De obicei, o specificație API REST este asociată cu documentația sa. Spre deosebire de o specificație - o descriere formală a API-ului dvs. - documentația este menită să fie citită de om: de exemplu, citită de dezvoltatorii aplicației mobile sau web care utilizează API-ul dvs.
O descriere corectă a API nu înseamnă doar scrierea corectă a documentației API. În acest articol vreau să vă împărtășesc exemple despre cum puteți:
- Faceți testele unitare mai simple și mai fiabile;
- Configurați preprocesarea și validarea intrărilor utilizatorului;
- Automatizează serializarea și asigură consecvența răspunsului; și chiar
- Bucurați-vă de beneficiile tastării statice.
Dar mai întâi, să începem cu o introducere în lumea specificațiilor API.
OpenAPI
OpenAPI este în prezent cel mai acceptat format pentru specificațiile REST API. Specificația este scrisă într-un singur fișier în format JSON sau YAML format din trei secțiuni:
- Un antet cu numele, descrierea și versiunea API, precum și orice informații suplimentare.
- Descrieri ale tuturor resurselor, inclusiv identificatori, metode HTTP, toți parametrii de intrare, coduri de răspuns și tipuri de date corporale, cu link-uri către definiții.
- Toate definițiile care pot fi folosite pentru intrare sau ieșire, în format JSON Schema (care, da, pot fi reprezentate și în YAML.)
Structura OpenAPI are două dezavantaje semnificative: este prea complexă și uneori redundantă. Un proiect mic poate avea o specificație JSON de mii de linii. Menținerea manuală a acestui fișier devine imposibilă. Aceasta este o amenințare semnificativă la adresa ideii de a menține specificația la zi în timp ce API-ul este în curs de dezvoltare.
Există mai multe editoare care vă permit să descrieți un API și să produceți o ieșire OpenAPI. Serviciile suplimentare și soluțiile cloud bazate pe acestea includ Swagger, Apiary, Stoplight, Restlet și multe altele.
Cu toate acestea, aceste servicii au fost incomode pentru mine din cauza complexității editării rapide a specificațiilor și alinierii acesteia cu modificările codului. În plus, lista de funcții depindea de un anumit serviciu. De exemplu, crearea de teste unitare cu drepturi depline bazate pe instrumentele unui serviciu cloud este aproape imposibilă. Generarea de cod și punctele finale batjocoritoare, deși par a fi practice, se dovedesc a fi în mare parte inutile în practică. Acest lucru se datorează în mare parte faptului că comportamentul punctului final depinde de obicei de diverse lucruri, cum ar fi permisiunile utilizatorului și parametrii de intrare, care pot fi evidente pentru un arhitect API, dar nu sunt ușor de generat automat dintr-o specificație OpenAPI.
Tinyspec
În acest articol, voi folosi exemple bazate pe propriul format de definire a API-ului REST, tinyspec . Definițiile constau în fișiere mici cu o sintaxă intuitivă. Ele descriu punctele finale și modelele de date care sunt utilizate într-un proiect. Fișierele sunt stocate lângă cod, oferind o referință rapidă și posibilitatea de a fi editate în timpul scrierii codului. Tinyspec este compilat automat într-un format OpenAPI cu drepturi depline, care poate fi utilizat imediat în proiectul dumneavoastră.
Voi folosi, de asemenea, exemple Node.js (Koa, Express) și Ruby on Rails, dar practicile pe care le voi demonstra sunt aplicabile pentru majoritatea tehnologiilor, inclusiv Python, PHP și Java.
În cazul în care specificațiile API sunt sănătoase
Acum că avem niște informații, putem explora cum să profităm la maximum de un API specificat corespunzător.
1. Teste unitare ale punctului final
Dezvoltarea bazată pe comportament (BDD) este ideală pentru dezvoltarea API-urilor REST. Cel mai bine este să scrieți teste unitare nu pentru clase, modele sau controlere separate, ci pentru anumite puncte finale. În fiecare test emulați o cerere HTTP reală și verificați răspunsul serverului. Pentru Node.js există pachetele supertest și chai-http pentru emularea solicitărilor, iar pentru Ruby on Rails există airborne.
Să presupunem că avem o schemă de User
și un punct final GET /users
care returnează toți utilizatorii. Iată o sintaxă tinyspec care descrie acest lucru:
# user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}
Și iată cum am scrie testul corespunzător:
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]); }); });
Ruby pe șine
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
Când avem deja specificația care descrie răspunsurile serverului, putem simplifica testul și doar să verificăm dacă răspunsul urmează specificația. Putem folosi modele tinyspec, fiecare dintre acestea putând fi transformat într-o specificație OpenAPI care urmează formatul Schema JSON.
Orice obiect literal în JS (sau Hash
în Ruby, dict
în Python, matrice asociativă în PHP și chiar Map
în Java) poate fi validat pentru conformitatea cu schema JSON. Există chiar și pluginuri adecvate pentru testarea cadrelor, de exemplu jest-ajv (npm), chai-ajv-json-schema (npm) și json_matchers pentru RSpec (rubygem).
Înainte de a folosi schemele, să le importăm în proiect. Mai întâi, generați fișierul openapi.json
pe baza specificației tinyspec (puteți face acest lucru automat înainte de fiecare test):
tinyspec -j -o openapi.json
Node.js
Acum puteți utiliza JSON generat în proiect și puteți obține cheia de definitions
din acesta. Această cheie conține toate schemele JSON. Schemele pot conține referințe încrucișate ( $ref
), așa că dacă aveți vreo schemă încorporată (de exemplu, Blog {posts: Post[]}
), trebuie să le dezactivați pentru a le utiliza în validare. Pentru aceasta, vom folosi 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); }); });
Ruby pe șine
Modulul json_matchers
știe cum să gestioneze referințele $ref
, dar necesită fișiere de schemă separate în locația specificată, așa că mai întâi va trebui să împărțiți fișierul swagger.json
în mai multe fișiere mai mici:
# ./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
Iată cum va arăta testul:
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
Scrierea testelor în acest fel este incredibil de convenabilă. Mai ales dacă IDE-ul tău acceptă rularea de teste și depanare (de exemplu, WebStorm, RubyMine și Visual Studio). În acest fel, puteți evita utilizarea altor software, iar întregul ciclu de dezvoltare a API-ului este limitat la trei pași:
- Proiectarea specificației în fișiere tinyspec.
- Scrierea unui set complet de teste pentru punctele finale adăugate/editate.
- Implementarea codului care satisface testele.
2. Validarea datelor de intrare
OpenAPI descrie nu numai formatul de răspuns, ci și datele de intrare. Acest lucru vă permite să validați datele trimise de utilizator în timpul execuției și să asigurați actualizări consistente și sigure ale bazei de date.
Să presupunem că avem următoarea specificație, care descrie corecțiile unei înregistrări de utilizator și toate câmpurile disponibile care pot fi actualizate:
# user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}
Anterior, am explorat pluginurile pentru validarea în test, dar pentru cazuri mai generale, există modulele de validare ajv (npm) și json-schema (rubygem). Să le folosim pentru a scrie un controler cu validare:
Node.js (Koa)
Acesta este un exemplu pentru Koa, succesorul Express, dar codul Express echivalent ar arăta similar.
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; } }
În acest exemplu, serverul returnează un răspuns 500 Internal Server Error
dacă intrarea nu se potrivește cu specificația. Pentru a evita acest lucru, putem prinde eroarea validatorului și putem forma propriul nostru răspuns care va conține informații mai detaliate despre anumite câmpuri care nu au reușit validarea și să respectăm specificația.
Să adăugăm definiția pentru FieldsValidationError
:
# error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}
Și acum să-l listăm ca unul dintre posibilele răspunsuri la punctul final:
# users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError
Această abordare vă permite să scrieți teste unitare care testează corectitudinea scenariilor de eroare atunci când date nevalide provin de la client.
3. Serializarea modelului
Aproape toate cadrele de server moderne folosesc maparea relațională obiect (ORM) într-un fel sau altul. Aceasta înseamnă că majoritatea resurselor pe care le folosește un API sunt reprezentate de modele și de instanțele și colecțiile acestora.

Procesul de formare a reprezentărilor JSON pentru aceste entități care urmează să fie trimise în răspuns se numește serializare .
Există o serie de pluginuri pentru realizarea serializării: De exemplu, sequelize-to-json (npm), acts_as_api (rubygem) și jsonapi-rails (rubygem). Practic, aceste plugin-uri vă permit să furnizați lista de câmpuri pentru un anumit model care trebuie inclus în obiectul JSON, precum și reguli suplimentare. De exemplu, puteți redenumi câmpurile și calcula valorile lor în mod dinamic.
Devine mai greu atunci când aveți nevoie de mai multe reprezentări JSON diferite pentru un model sau când obiectul conține entități imbricate - asociații. Apoi începeți să aveți nevoie de funcții precum moștenirea, reutilizarea și conectarea la serializator.
Module diferite oferă soluții diferite, dar să luăm în considerare acest lucru: specificația poate ajuta din nou? Practic, toate informațiile despre cerințele pentru reprezentările JSON, toate combinațiile posibile de câmpuri, inclusiv entitățile încorporate, sunt deja în el. Și asta înseamnă că putem scrie un singur serializator automat.
Permiteți-mi să vă prezint modulul mic sequelize-serialize (npm), care acceptă acest lucru pentru modelele Sequelize. Acceptă o instanță de model sau o matrice și schema necesară, apoi o iterează pentru a construi obiectul serializat. De asemenea, ține cont de toate câmpurile necesare și utilizează scheme imbricate pentru entitățile asociate acestora.
Deci, să presupunem că trebuie să returnăm toți utilizatorii cu postări în blog, inclusiv comentariile la aceste postări, din API. Să-l descriem cu următoarele specificații:
# 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[]}
Acum putem construi cererea cu Sequelize și returnăm obiectul serializat care corespunde exact specificației descrise mai sus:
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); });
Este aproape magic, nu-i așa?
4. Tastare statică
Dacă sunteți suficient de cool pentru a utiliza TypeScript sau Flow, s-ar putea să vă fi întrebat deja „Ce sunt tipurile mele statice prețioase?!” Cu modulele sw2dts sau swagger-to-flowtype puteți genera toate tipurile statice necesare pe baza schemelor JSON și le puteți utiliza în teste, controlere și serializatoare.
tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api
Acum putem folosi tipuri în controlere:
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 }; });
Si teste:
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); });
Rețineți că definițiile de tip generate pot fi utilizate nu numai în proiectul API, ci și în proiectele de aplicații client pentru a descrie tipurile din funcțiile care funcționează cu API. (Dezvoltatorii Angular vor fi deosebit de fericiți de acest lucru.)
5. Casting tipuri de șiruri de interogări
Dacă API-ul dvs. din anumite motive consumă solicitări cu tipul MIME application/x-www-form-urlencoded
în loc de application/json
, corpul solicitării va arăta astfel:
param1=value¶m2=777¶m3=false
Același lucru este valabil și pentru parametrii de interogare (de exemplu, în cererile GET
). În acest caz, serverul web nu va recunoaște automat tipurile: Toate datele vor fi în format șir, așa că după analizare veți obține acest obiect:
{ param1: 'value', param2: '777', param3: 'false' }
În acest caz, cererea nu va eșua validarea schemei, așa că trebuie să verificați manual formatele corecte ale parametrilor și să le transformați în tipurile corecte.
După cum puteți ghici, o puteți face cu vechile noastre scheme bune din specificație. Să presupunem că avem acest punct final și următoarea schemă:
# posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }
Iată cum arată cererea către acest punct final:
GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true
Să scriem funcția castQuery
pentru a proiecta toți parametrii la tipurile necesare:
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; } }); }
O implementare mai completă, cu suport pentru scheme imbricate, matrice și tipuri null
este disponibilă în modulul cast-with-schema (npm). Acum să-l folosim în codul nostru:
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) }; });
Rețineți că trei dintre cele patru linii de cod folosesc scheme de specificații.
Cele mai bune practici
Există o serie de bune practici pe care le putem urma aici.
Utilizați scheme separate pentru creare și editare
De obicei, schemele care descriu răspunsurile serverului sunt diferite de cele care descriu intrările și sunt folosite pentru a crea și edita modele. De exemplu, lista de câmpuri disponibile în solicitările POST
și PATCH
trebuie să fie strict limitată, iar PATCH
are de obicei toate câmpurile marcate ca opțional. Schemele care descriu răspunsul pot fi mai libere.
Când generați automat puncte finale CRUDL, tinyspec folosește postfixele New
și Update
. Schemele User*
pot fi definite în felul următor:
User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}
Încercați să nu utilizați aceleași scheme pentru diferite tipuri de acțiuni pentru a evita problemele accidentale de securitate din cauza reutilizării sau moștenirii schemelor mai vechi.
Urmați convențiile de denumire a schemelor
Conținutul acelorași modele poate varia pentru puncte finale diferite. Utilizați postfixele With*
și For*
în numele schemelor pentru a arăta diferența și scopul. În tinyspec, modelele se pot moșteni și unul de la celălalt. De exemplu:
User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}
Postfixele pot fi variate și combinate. Numele lor trebuie să reflecte în continuare esența și să facă documentația mai ușor de citit.
Separarea punctelor finale în funcție de tipul de client
Adesea, același punct final returnează date diferite în funcție de tipul de client sau de rolul utilizatorului care a trimis cererea. De exemplu, punctele finale GET /users
și GET /messages
pot fi semnificativ diferite pentru utilizatorii de aplicații mobile și managerii de back office. Schimbarea numelui punctului final poate fi generală.
Pentru a descrie același punct final de mai multe ori, puteți adăuga tipul acestuia în paranteze după cale. Acest lucru facilitează, de asemenea, utilizarea etichetelor: împărțiți documentația endpoint-ului în grupuri, fiecare dintre acestea fiind destinat unui anumit grup de clienți API. De exemplu:
Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]
Instrumente de documentare REST API
După ce obțineți specificația în format tinyspec sau OpenAPI, puteți genera documentație frumos în format HTML și o puteți publica. Acest lucru îi va bucura pe dezvoltatorii care folosesc API-ul dvs. și cu siguranță este mai bine să completeze manual un șablon de documentație API REST.
În afară de serviciile cloud menționate mai devreme, există instrumente CLI care convertesc OpenAPI 2.0 în HTML și PDF, care pot fi implementate în orice găzduire statică. Aici sunt cateva exemple:
- bootprint-openapi (npm, folosit implicit în tinyspec)
- swagger2markup-cli (jar, există un exemplu de utilizare, va fi folosit în tinyspec Cloud)
- redoc-cli (npm)
- late (npm)
Mai ai exemple? Distribuiți-le în comentarii.
Din păcate, în ciuda faptului că a fost lansat în urmă cu un an, OpenAPI 3.0 este încă slab acceptat și nu am reușit să găsesc exemple adecvate de documentație bazată pe acesta atât în soluțiile cloud, cât și în instrumentele CLI. Din același motiv, tinyspec nu acceptă încă OpenAPI 3.0.
Publicare pe GitHub
Una dintre cele mai simple moduri de a publica documentația este GitHub Pages. Doar activați suportul pentru paginile statice pentru folderul dvs. /docs
în setările depozitului și stocați documentația HTML în acest folder.
Puteți adăuga comanda pentru a genera documentație prin tinyspec sau un alt instrument CLI în fișierul scripts/package.json
pentru a actualiza automat documentația după fiecare comitere:
"scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }
Integrare continuă
Puteți adăuga generarea de documentație la ciclul CI și o puteți publica, de exemplu, pe Amazon S3 la adrese diferite, în funcție de mediu sau de versiunea API (cum ar fi /docs/2.0
, /docs/stable
și /docs/staging
.)
Tinyspec Cloud
Dacă vă place sintaxa tinyspec, puteți deveni un utilizator timpuriu pentru tinyspec.cloud. Intenționăm să construim un serviciu cloud bazat pe acesta și un CLI pentru implementarea automată a documentației cu o gamă largă de șabloane și capacitatea de a dezvolta șabloane personalizate.
Specificație REST: un mit minunat
Dezvoltarea API-ului REST este probabil unul dintre cele mai plăcute procese în dezvoltarea serviciilor web și mobile moderne. Nu există browser, sistem de operare și grădini zoologice de dimensiunea ecranului, iar totul este pe deplin sub controlul tău, la îndemâna ta.
Acest proces este simplificat și de suportul pentru automatizare și specificații actualizate. Un API care folosește abordările descrise de mine devine bine structurat, transparent și fiabil.
Concluzia este că, dacă facem un mit, de ce să nu facem din acesta un mit minunat?