Cum proiectezi un API REST bun
Resurse, metode HTTP, coduri de status, erori, paginare, versionare și OpenAPI, cu reguli și exemple practice.
Un API este un contract: odată ce alți developeri, sau aplicația ta mobilă instalată pe mii de telefoane, depind de el, fiecare decizie proastă devine greu de reparat. Vestea bună este că un API REST bun nu presupune nimic exotic, ci aplicarea consecventă a câtorva reguli despre resurse, metode HTTP, coduri de status și erori. Ghidul de față le adună pe toate, cu exemple pe care le poți folosi direct.
Ce înseamnă REST, pe scurt
REST (Representational State Transfer) este un stil arhitectural descris de Roy Fielding în teza sa de doctorat din 2000. Ideile care contează în practică:
- Resurse. Totul este o resursă identificată printr-un URL: un utilizator, o comandă, o listă de produse.
- Reprezentări. Clientul nu primește resursa în sine, ci o reprezentare a ei, de obicei JSON.
- Interfață uniformă. Operațiile se exprimă prin metodele standard HTTP, nu prin verbe inventate în URL.
- Stateless. Fiecare cerere conține tot ce îi trebuie serverului ca s-o proceseze, inclusiv autentificarea. Serverul nu ține minte „conversația”.
Puține API-uri respectă REST ad litteram (de exemplu principiul HATEOAS, cu link-uri navigabile în fiecare răspuns). Când oamenii spun „API REST”, se referă de obicei la un API HTTP orientat pe resurse, care vorbește JSON. Despre acesta este vorba aici.
Resursele: substantive, nu verbe
URL-ul descrie ce, metoda HTTP descrie ce faci cu el.
# Așa nu
POST /getUsers
POST /createOrder
GET /deleteProduct?id=42
# Așa da
GET /users
POST /orders
DELETE /products/42
Reguli care te scutesc de discuții lungi în code review:
- Plural pentru colecții:
/users,/users/42. Consecvența contează mai mult decât alegerea în sine. - Litere mici și kebab-case în path:
/learning-paths, nu/LearningPaths. - Imbricare cu măsură.
/users/42/orderseste clar;/users/42/orders/7/items/3/reviewseste prea mult. După un nivel, preferă resurse de sine stătătoare (/order-items/3) sau filtre (/reviews?orderItemId=3). - Fără extensii de tipul
/users.json; formatul se negociază prin header-ulAccept. - Acțiunile care nu se potrivesc în CRUD pot deveni sub-resurse,
POST /orders/7/cancellation, sau, pragmatic,POST /orders/7/cancel. Ambele variante se întâlnesc; important este să alegi una și s-o aplici peste tot.
Pentru numele câmpurilor din JSON alege o convenție (camelCase sau snake_case) și respect-o în toate endpoint-urile.
Metodele HTTP și semantica lor
| Metodă | Folosire | Safe | Idempotentă | Răspuns tipic |
|---|---|---|---|---|
GET | Citește o resursă sau o colecție | Da | Da | 200 |
POST | Creează o resursă sau declanșează o acțiune | Nu | Nu | 201 + Location |
PUT | Înlocuiește complet resursa | Nu | Da | 200 sau 204 |
PATCH | Modifică parțial resursa | Nu | Nu neapărat | 200 |
DELETE | Șterge resursa | Nu | Da | 204 |
- Safe înseamnă că cererea nu modifică nimic pe server. Un
GETcare șterge date este o capcană: crawler-ele, prefetch-ul și cache-urile presupun căGETeste inofensiv. - Idempotentă înseamnă că aceeași cerere trimisă de mai multe ori are același efect ca trimisă o singură dată.
DELETE /products/42repetat lasă produsul tot șters. Proprietatea aceasta permite reîncercarea sigură după un timeout.
Crearea unei resurse, făcută corect:
POST /orders HTTP/1.1
Content-Type: application/json
{ "productId": "prd_81", "quantity": 2 }
HTTP/1.1 201 Created
Location: /orders/ord_1029
Content-Type: application/json
{ "id": "ord_1029", "productId": "prd_81", "quantity": 2, "status": "pending" }
Răspunsul returnează resursa creată, inclusiv câmpurile generate de server (id, status), ca clientul să nu fie nevoit să facă imediat încă un GET.
Coduri de status care spun adevărul
Un API care răspunde 200 OK cu { "error": "not found" } în body obligă fiecare client să parseze body-ul ca să afle dacă cererea a reușit. Folosește codurile pentru ce au fost gândite:
| Cod | Când îl folosești |
|---|---|
200 OK | Cerere reușită, cu body |
201 Created | Resursă creată; include header-ul Location |
204 No Content | Reușită, fără body (de exemplu după DELETE) |
400 Bad Request | Cerere malformată: JSON invalid, parametri lipsă |
401 Unauthorized | Autentificarea lipsește sau este invalidă |
403 Forbidden | Utilizatorul este autentificat, dar nu are voie |
404 Not Found | Resursa nu există |
409 Conflict | Conflict cu starea curentă (de exemplu e-mail deja folosit) |
422 Unprocessable Content | JSON valid, dar datele nu trec validarea |
429 Too Many Requests | Limita de cereri a fost depășită; adaugă Retry-After |
500 Internal Server Error | Eroare neașteptată pe server |
503 Service Unavailable | Serviciul este temporar indisponibil |
Diferența dintre 401 și 403 apare la aproape orice interviu: 401 înseamnă „nu știu cine ești”, 403 înseamnă „știu cine ești, dar nu ai voie”. Pentru resurse private se răspunde uneori intenționat cu 404 în loc de 403, ca să nu confirmi că resursa există.
Erori consistente: Problem Details
Clienții tăi vor scrie cod care tratează erorile, așa că formatul lor trebuie să fie previzibil. Există un standard pentru asta, RFC 9457: Problem Details for HTTP APIs, cu tipul de conținut application/problem+json:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Datele trimise nu sunt valide",
"status": 422,
"detail": "Câmpul quantity trebuie să fie între 1 și 10.",
"instance": "/orders",
"errors": [
{ "field": "quantity", "message": "Valoarea maximă este 10" }
]
}
Câmpurile type, title, status, detail și instance sunt definite de standard, iar extensiile precum errors sunt permise. Încă două reguli:
- Nu expune stack trace-uri, interogări SQL sau mesaje interne în răspunsuri. Le loghezi pe server, cu un identificator de corelare pe care îl poți returna și clientului.
- Mesajele de validare trebuie să spună ce câmp este greșit și de ce, astfel încât un frontend să le poată afișa lângă câmpul respectiv.
Paginare, filtrare și sortare
Nicio colecție nu rămâne mică. Paginează de la prima versiune, chiar dacă azi ai doar 20 de înregistrări.
Offset pagination este simplă și permite salt direct la pagina N:
GET /products?limit=20&offset=40 HTTP/1.1
Problema: la offset-uri mari baza de date parcurge și aruncă toate rândurile anterioare, iar dacă între timp se inserează elemente, utilizatorul vede duplicate sau sare peste unele.
Cursor pagination (keyset) folosește un marcaj opac al ultimului element văzut:
GET /products?limit=20&cursor=eyJpZCI6InByZF8xMDIwIn0 HTTP/1.1
{
"data": [
{ "id": "prd_1021", "name": "Tastatură mecanică" }
],
"nextCursor": "eyJpZCI6InByZF8xMDQwIn0"
}
Este stabilă și rapidă pe volume mari, dar nu permite salt direct la o pagină anume. Pentru feed-uri și liste cu scroll infinit este alegerea potrivită.
Pentru filtrare și sortare, query string-ul este locul natural:
GET /products?category=laptops&minPrice=2000&sort=-createdAt,name HTTP/1.1
Convenția cu - pentru sortare descrescătoare este răspândită. Validează câmpurile acceptate la sortare și filtrare după o listă fixă: nu trimite orbește în SQL numele unei coloane venit din URL.
Versionare și compatibilitate
Odată publicat, un API nu se mai poate schimba după bunul plac. Distinge între:
- Schimbări compatibile: un câmp nou în răspuns, un endpoint nou, un parametru opțional nou. Clienții existenți continuă să funcționeze, cu condiția să ignore câmpurile pe care nu le cunosc.
- Breaking changes: redenumirea sau ștergerea unui câmp, schimbarea tipului, un parametru devenit obligatoriu, alt cod de status pentru aceeași situație.
Pentru breaking changes ai nevoie de o versiune nouă. Variantele uzuale:
| Strategie | Exemplu | Observații |
|---|---|---|
| În path | /v1/orders | Cea mai vizibilă, ușor de testat din browser și din curl |
În header-ul Accept | application/vnd.example.v2+json | URL-uri curate, dar mai greu de depanat |
| Pe dată, într-un header | Api-Version: 2026-01-15 | Granulară, dar cere disciplină internă |
Indiferent de variantă, anunță depreciarea din timp, documentează migrarea și păstrează versiunea veche activă o perioadă rezonabilă.
Idempotență și reîncercări
Rețelele pică. Dacă un client trimite POST /payments și primește timeout, nu știe dacă plata s-a făcut, iar o reîncercare naivă poate dubla plata. Soluția răspândită este header-ul Idempotency-Key, popularizat de API-urile de plăți precum Stripe și aflat în curs de standardizare la IETF:
POST /payments HTTP/1.1
Idempotency-Key: 5f8c2a3e-7b1d-4c9a-9e2f-1a2b3c4d5e6f
Content-Type: application/json
{ "amount": 4900, "currency": "RON" }
Serverul salvează cheia împreună cu rezultatul. Dacă aceeași cheie vine din nou, returnează rezultatul salvat în loc să proceseze plata a doua oară. Observă și suma: 4900 înseamnă 49,00 lei, exprimată în bani, ca număr întreg. Sumele de bani nu se transmit ca numere în virgulă mobilă.
Securitate și limite
- Doar HTTPS, fără excepții, inclusiv pentru mediile de test expuse public.
- Autentificarea în header, de exemplu
Authorization: Bearer <token>, niciodată în query string: URL-urile ajung în loguri, în istoricul browserului și în header-ulReferer. - Autorizare pe fiecare resursă. Faptul că utilizatorul este logat nu înseamnă că are voie la
/orders/1029. Verifică explicit că resursa îi aparține; altfel ai o vulnerabilitate de tip IDOR (Insecure Direct Object Reference). - Rate limiting. Răspunde cu
429șiRetry-After, ca să protejezi serviciul de abuz și de clienții scriși greșit. - Validarea intrării după o schemă (JSON Schema, Zod, Pydantic) și respingerea câmpurilor neașteptate, ca să eviți mass assignment: un
"role": "admin"strecurat într-unPATCH /users/menu trebuie să ajungă în baza de date. - Limite de dimensiune pentru body-uri și pagini, de exemplu
limitmaxim 100.
Documentația: OpenAPI
Un API fără documentație este un API pe care nimeni nu îl folosește corect. Standardul de facto este OpenAPI, o descriere în YAML sau JSON a endpoint-urilor, parametrilor și schemelor de date:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/orders/{orderId}:
get:
summary: Returnează o comandă
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Comanda a fost găsită
"404":
description: Comanda nu există
Din aceeași specificație poți genera documentație interactivă, clienți pentru diverse limbaje și teste de contract. Multe framework-uri, precum FastAPI, NestJS, ASP.NET Core sau Spring Boot (cu springdoc-openapi), o pot genera direct din cod. Specificația și uneltele asociate sunt descrise pe openapis.org.
Checklist înainte de lansare
| Zonă | Verificare |
|---|---|
| URL-uri | Substantive la plural, kebab-case, imbricare de cel mult un nivel |
| Metode | GET fără efecte secundare, PUT și DELETE idempotente |
| Status | Coduri corecte, 201 + Location la creare |
| Erori | Format unic (Problem Details), fără detalii interne |
| Colecții | Paginare cu limită maximă, filtre și sortare validate |
| Evoluție | Strategie de versionare stabilită înainte de primul client |
| Securitate | HTTPS, autorizare per resursă, rate limiting, validare după schemă |
| Documentație | Specificație OpenAPI actualizată automat |
Când ai nelămuriri despre semantica exactă a unei metode sau a unui cod de status, sursa de referință este RFC 9110: HTTP Semantics.
Pașii următori
- Construiește un API mic, de exemplu o listă de sarcini cu utilizatori, aplicând checklist-ul de mai sus, apoi scrie-i specificația OpenAPI și testează-l cu curl.
- Pentru fundamentele de server (baze de date, autentificare, testare), urmează roadmap-ul Backend; dacă lucrezi în JavaScript, continuă cu roadmap-ul Node.js.
- Când API-ul începe să crească (cache, rate limiting distribuit, cozi de mesaje), treci la System Design.