Acest articol este partea a doua dintr-o serie în care arătăm cum se pregătește, planifică și implementează un API RESTful. După ce am pus bazele în prima parte, de data aceasta ne vom uita la cum se face designul unui API, care sunt aspectele la care trebuie să fim atenți. Dar mai întâi ne vom uita la ce înseamnă REST și la constrângerile arhitecturale pe care le impune asupra sistemului.
Representational State Transfer este un stil de arhitectură de aplicație care, în loc să impună decizii asupra tehnologiei, preferă să definească un set de constrângeri la care sistemul să adere. În felul acesta, detaliile de implementare se pot schimba ulterior, dar să se păstreze avantajele care decurg din abordarea RESTful.
O resursă reprezintă orice informație care poate fi denumită. De obicei acestea sunt concepte din domeniul aplicației, indiferent că se referă la concepte concrete (ex: persoane) sau la unele abstracte (ex: fișiere). Un bun de pornire în "vizualizarea" acestora pentru cei familiarizați cu OOP este să folosească o mapare unu-la-unu cu clasele ce compun modelul domeniului. Deoarece noi construim o aplicație de monitorizat datele despre vehicule, putem exemplifica două concepte: vehicul și asigurare. Dificultatea explicării termenului de resursă provine din faptul că aspecte particulare de business pot dicta deviații de la cel mai intuitiv mod de reprezentare, fără ca acest lucru să înlăture validitatea.
REST a fost descris în mod formal de Roy J. Fielding în teza sa de doctorat. El pleacă de la un sistem care nu are delimitări clare între componente, și aplică incremental cinci constrângeri obligatorii și una opțională asupra elementelor care compun arhitectura:
client-server: separarea aspectelor interfeței utilizator de stocarea datelor
fără stare: păstrarea detaliilor despre sesiune strict la client, eliberând astfel serverul de povara managementului sesiunilor, pentru a aduce scalabilitate, siguranță, și vizibilitate; dezavantajul este că fiecare cerere va trebui să conțină suficiente informații încât să poată fi procesată corect
cache: datele care compun un răspuns trebuie să fie etichetate ca și cache-uibile sau non-cache-uibile
interfață uniformă între componente, așa cum e definită de următoarele constrângeri secundare: identificarea resurselorș manipularea resurselor prin intermediul reprezentărilor acestoraș mesaje auto-descriptive; și hypermedia ca motor al stării aplicației (aka HATEOAS)
sistem stratificat: componenta poate să "vadă" și să interacționeze doar cu straturile din imediata sa apropiere; spre exemplu, clienții nu pot presupune că interacționează direct cu sursa de date, deoarece pot comunica cu un nivel de cache
Reprezentarea este o parte a stării resursei care este transferată între client și server. Se referă de obicei la starea curentă a resursei, dar poate indica și starea dorită, ne putem gândi la acest lucru ca la un dry-run atunci când se face cererea.
Deși nu constituie o constrângere în sine, mecanismele de comunicare oferite de HTTP sunt alegerea celor mai mulți dezvoltatori care implementează REST. Și noi vom folosi HTTP în acest proiect, iar verbele sale ne vor ajuta să definim operațiile care se efectuează asupra resurselor noastre. În cele ce urmează vom folosi paradigma puternică a calendarului pentru a ilustra ușor diferența dintre resursă și reprezentarea acesteia, prin studierea reprezentării .ics
în prima fază:
GET /calendar/123sample
Host example.dev
Accept: text/calendar
ar putea returna ceva similar cu:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Tekkie Consulting//123sample//EN
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Bucharest
END:VTIMEZONE
BEGIN:VEVENT
UID:123456789@example.dev
DTSTART;TZID=Z:20150311T123456
DTEND;TZID=Z:20150311T125959
END:VEVENT
END:VCALENDAR
Dacă vom cere o reprezentare JSON a aceleași resurse
GET /calendar/123sample
Host example.dev
Accept: application/json
ea ar putea arăta ca în exemplul de mai jos:
{
"version": "2.0",
"creator": {"company": "Tekkie Consulting", "product": "123sample"},
"type": "Gregorian",
"language": "English",
"timezone": {
"id": "Europe/Bucharest"
},
"events": [{
"id": "123456789@example.dev",
"start": "2015-03-11T12:34:56.000Z",
"end": "2015-03-11T12:59:59.000Z"
}]
}
În continuarea acestei serii de articole vom întrebuința JSON ca reprezentare implicită a resurselor noastre.
Pentru a specifica felul în care se comportă APIul nostru, ne dorim să folosim Behaviour Driven Development. În această secțiune vom introduce câteva concepte și instrumente care să ne ajute. Această pregătire ne va fi de mare folos atunci când vom scrie cerințele în forma scenariilor de utilizare.
Sunt două cuvinte cheie care apar de fiecare dată în discuțiile despre BDD în PHP. Cel mai popular este, fără îndoială Behat, care folosește Gherkin pentru specificarea cerințelor în limbaj apropiat de cel business. Celălalt este Codeception, care întrebuințează cod PHP ușor de citit pentru a obține același lucru, și care oferă în plus instrumente pentru a facilita scrierea tuturor testelor în același mod (atât cele de acceptanță, funcționale, cât și testele unitare).
În trecut am folosit Behat destul de mult la proiectele mele Symfony, așa că în mod natural am încercat să merg pe calea cea mai ușoară și să il instalez folosind $ composer install behat/behat
. Am observat conflicte de pachete deoarece Behat dorea să folosească symfony/event-dispatcher ~2.1
iar eu aveam deja instalat 3.0.1. Așa că, în loc să încerc să rezolv problema prin impunerea unor cerințe mai stricte asupra pachetelor, am decis că e momentul potrivit să încerc Codeception, mai ales că am primit feedback extrem de călduros de la cei care îl folosesc deja în munca lor de zi cu zi. Am lăsat în urmă frumoasele fișiere Gherkin numai bune pentru oamenii de business, am revenit la 100% PHP!
Așadar am instalat folosind metoda Composer care a funcționat perfect din prima încercare:
$ composer install codeception/codeception
apoi am rulat comanda de bootstrap
$ vendor/bin/codecept bootstrap
Se observă un folder numit /tests/
în rădăcina proiectului, care a fost populat cu tot feluld e bunătățuri.
Deoarece suntem în mediu de dezvoltare, pornim aplicația local folosind serverul PHP built-in:
$ php -S localhost:12345 -t web
și ne asigurăm că acest URL este configurat corect în fișierul tests/acceptance.suite.yml
.
În continuare vom genera un test de acceptanță simplu, rulând comanda:
$ vendor/bin/codecept generate:cept acceptance Welcome
Fișierul tests/acceptance/WelcomeCept.php
nou creat va conține un test simplu, menit doar să verifice ruta de status:
$I = new AcceptanceTester($scenario);
$I->wantTo('check the status route');
$I->amOnPage('/');
$I->see('Up and running.');
Iar rezultatele sunt într-adevăr cele așteptate:
$ vendor/bin/codecept run
Codeception PHP Testing Framework v2.1.5
Powered by PHPUnit 4.8.21 by Sebastian Bergmann and contributors.
Acceptance Tests (1) ---------------------------------------------
Check the status route (WelcomeCept) Ok
------------------------------------------------------------------
Functional Tests (0) ------------------------
---------------------------------------------
Unit Tests (0) ------------------------------
---------------------------------------------
Time: 214 ms, Memory: 11.25Mb
OK (1 test, 1 assertion)
Testele de acceptanță Codeception sunt de regulă mai lente decât cele funcționale, deoarece este nevoie de un server web pentru a rula testele. Din fericire, există teste funcționale, care ne vor fi de mare folos în descrierea funcționalității APIului, așa că le vom folosi pe acelea. Mai întâi activăm modulul Silex
, adăugându-l în tests/functional.suite.yml
:
class_name: FunctionalTester
modules:
enabled:
- Silex:
app: 'app/bootstrap.php'
- \Helper\Functional
Așa cum am văzut în secțiunea introductivă REST, un concept cheie în acest stil arhitectural sunt resursele, toate celelalte lucruri gravitând în jurul lor. De aceea recomand să trecem prin lista conceptelor domeniului mai întâi, pentru a identifica resursele noastre:
vehicul - conceptul principal, care descrie resursa principala a aplicației noastre
taxa de drum - are o dată de început și una de sfârșit a validității, și este asociată unui vehicul
asigurare - un concept distinct, similar ca proprietăți cu taxa de drum
Acum că am clarificat care sunt resursele aplicației noastre, ce operații putem defini pe fiecare? Cu siguranță ne dorim să putem crea, modifica și obține date despre vehicule. Apoi avem nevoie să adăugăm și obținem detalii despre taxa de drum, verificările periodice, precum și despre asigurare.
Toate operațiile de mai sus arată foarte asemănător cu cele CRUD (o parte din ele lipsesc din motive care țin de domeniu). Așadar, haideți să definim lista completă de operații CRUD pe o resursă de tip vehicul:
citirea informațiilor despre colecția de vehicule
GET /vehicles
Dacă nu există elemente, vom primi status 200 OK
și conținut []
Dacă există elemente în storage vom primi status 200 OK
și conținutul tuturor, ca de exemplu: [{ "name": "family car", "make": "Mazda", "model": "3", "registration": "AB10TKK", "VIN": "JMZBLA2F701213123", "engine": "1999", "emissions": "175", "registered": "2010-10-01T12:23:45Z" }]
500 Internal Server Error
va fi expusă. Aceasta este eroarea finală ("catch-all") care se emite în cazul în care "serverul e de vină" și nu poate fi oferită nici o opțiune de recovery pentru client. Acest tip de eroare este aplicabilă tuturor operațiilor de mai jos, așadar nu o vom repeta.Creare vehicul nou
POST /vehicles/
Conținutul requestului va avea toate informațiile necesare despre mașină, spre exemplu { "name": "family car", "make": "Mazda", "model": "3", "registration": "AB10TKK", "VIN": "JMZBLA2F701213123", "engine": "1999", "emissions": "175", "registered": "2010-10-01T12:23:45Z" }
La crearea cu succes a resursei, ne așteptăm la un status 200 OK
(unii recomandă folosirea 201 Created
) iar conținutul va avea aceleași date ca și requestul, plus IDul pe care l-a primit resursa în backend.
400 Bad Request
și a conținutului care descrie problema: { "VIN": "mandatory" }
Citire informații despre un vehicul anume
GET /vehicles/{id}
Dacă vehiculul cu IDul dat există, ne așteptăm la un status 200 OK
și la un conținut care să îl descrie.
404 Not Found
și corpul răspunsului va fi gol.Editare
PUT /vehicles/{id}
Dacă vehiculul cu IDul dat există, ne așteptăm la un status 200 OK
și la un conținut care să îl descrie.
400 Bad Request
și a unui conținut care să descrie problema: { "VIN": "mandatory" }
Ștergere
DELETE /vehicles/{id}
Dacă vehiculul cu IDul dat există, acesta va fi eliminat din sistem și un cod 200 OK
cu conținut gol va fi returnat.
404 Not Found
și corpul răspunsului va fi gol.Formatul de date din exemplul de mai sus este conform standardului ISO 8601, pentru a folosi un format care să conțină timezone. Facebook a fost nevoit să schimbe în 2012 API-ul de evenimente din cauza unor aspecte legate de timezone.
Nu vom detalia mai mult operațiile ce pot fi efectuate asupra celorlalte tipuri de resurse, deoarece ele sunt similare cu cele detaliate mai sus.
Este important să oferim un API consistent, nu doar pentru noi, ci și pentru terții care îl consumă. E foarte ușor de explicat folosind distincția între public și publicat pe care o face Martin Fowler. O dată ce un lucru este publicat, efectuarea de modificări asupra sa necesită un proces complex de deprecare a funcționalității existente, oferirea celei noi la alt URI, lucruri care iau mai mult timp și implică efort suplimentar fată de o modificare de cod obișnuită.
Un aspect important care trebuie avut în vedere încă de la început este versionarea APIului. Unii designeri de API preferă să folosească versiunea în URLul de bază, ca GitHub care întrebuințează numărul versiunii, sau ca Twilio care folosește data lansării https://api.twilio.com/2010-04-01
ca punct de pornire. WePay au și ei timestampul lansării în URL pentru a diferenția versiunile între ele. Aproape oricine a ales versionarea într-un fel sau altul, iar dacă vreți să jucați la siguranță e nevoie doar să începeți cu/v1/
. Cel mai mare avantaj al acestei abordări este posibilitatea de a avea un aPI foarte diferit în următoarea versiune. Cu toate acestea, e important să vă întrebați dacă APIul în sine e cel care va suferi modificări, sau resursele, sau reprezentările acestora. Strategiile de modificare a structurii sau operațiilor unei resurse non-versionate sunt puțin mai complexe, de obicei se oferă noile date la un URI diferit, și se marchează ca deprecated cel existent. Așa cum arată și Stefan Tilkov, REST este mult mai mult decât unele șabloane URI și verbele din HTTP. Alegerea strategiei optime este foarte dependentă de problema care trebuie rezolvată, și cu toții am auzit exemple de sub- sau supra-inginerie a APIurilor.
Când ne construim URIurile, trebuie să avem în vedere să nu amestecăm singularele și pluralurile. Așadar, nu vom expune simultan/vehicles/{id}
și /tax/{id}
în aceeași aplicație, vom alege să folosim taxes
în cel de-al doilea caz. Se recomandă în general folosirea formei plural în detrimentul celei singulare.
Deoarece URIurile identifică resurse, este considerată o bună practică folosirea în exclusivitatea a substantivelor în compunerea acestora, și evitarea verbelor. Pentru a descrie acțiunile care se pot efectua asupra resursei, se folosesc verbele HTTP. Spre exemplu, /vehicles/create
ar trebui să fie de fapt un POST către /vehicles
.
Pentru mai multe exemple aplicate, recomand standardele Casei Albe care sunt foarte bine scrise, ele conținând multe exemple concrete de ce anume este considerat "rău" și ce "bun". Sunt indicii bune și în tutorialul REST API, dar recomand prudență la navigarea prin acest site, deoarece nu toate informațiile de acolo aderă strict la principiile REST pe care le-am prezentat la începutul acestui articol.
Sfaturi chiar și mai granulare privesc convețiile de numire și folosirea snake-camel case, atât pentru URIuri cât și pentru structuri JSON. ASigurați-vă că alegeți unul și îl utilizați peste tot.
Să trecem la pregătirea comportamentului pentru resursele de tip vehicul prin definirea unor teste funcționale pentru acestea. Vom folosi clase Cest deoarece ulterior ne vor ajuta la o aranjare mai facilă a testelor noastre. Cest-urile sunt clase simple care grupează funcționalitatea din Cept-uri într-o manieră OOP, și ne vor facilita gruparea testelor împreună.
Generăm primul nostru Cest folosind:
$ vendor/bin/codecept generate:cest functional Vehicles
Test was created in /Users/g/Sites/learn/silex-tutorial/tests/functional/VehiclesCept.php
Apoi definim cum va arăta o resursă de tip vehicul. Spunem practic că "la execuția unei cereri POST către /vehicles
care primește un nume pentru noul item, îl vom crea și returna întreaga înregistrare, inclusiv IDul acesteia". Pe baza acestui ID vom efectua ulterior operații de modificare, la adresa /vehicles/ID
.
wantTo('create a new vehicle');
$I->sendPOST('/vehicles', [
'name' => 'Pansy'
]);
// we mark scenario as not implemented
$scenario->incomplete('work in progress');
$I->seeResponseCodeIs(200);
$I->seeResponseIsJson();
$I->seeResponseJsonMatchesXpath('//id');
$I->seeResponseJsonMatchesXpath('//name');
$I->seeResponseMatchesJsonType([
'id' => 'integer',
'name' => 'string'
]);
$I->seeResponseContainsJson([
'id' => 123,
'name' => 'Pansy'
]);
}
}
Să observăm că, cel puțin pentru moment, marcăm scenariul curent ca incomplet, pentru a preveni execuția aserțiilor noastre. Vom elimina acest lucru în partea următoare a tutorialului nostru, atunci când vom trece la implementarea efectivă a funcționalității.
Mai multe detalii despre celelalte operații definite pentru vehicule, precum și aserțiile corespunzătoare, pot fi găsite pe GitHub.
Rularea testelor funcționale indică faptul că avem câteva definite și că ele sunt încă incomplete, iată cele mai importante elemente de la rularea testelor:
$ vendor/bin/codecept run
Functional Tests (4) ----------------------------------------------------
Check the status route (StatusRouteCest::checkTheStatusRoute) Ok
Create a new vehicle (VehiclesCest::createItem) Incomplete
Retrieve current vehicles (VehiclesCest::retrieveItems) Incomplete
Modify an existing item (VehiclesCest::updateItem) Incomplete
-------------------------------------------------------------------------
Time: 372 ms, Memory: 12.00Mb
OK, but incomplete, skipped, or risky tests!
Tests: 4, Assertions: 1, Incomplete: 3.
Definirea celorlalte cazuri de utilizare rămâne ca exercițiu pentru cititor.
Am aflat ce este stilul arhitectural REST, am învățat diferența între resurse și reprezentările lor, am pregătit Codeception pentru a ne fi de folos la definirea cazurilor de utilizare, am învățat cum se face design de API din punct de vedere practic, și am văzut la ce trebuie să fim atenți în cadrul acestui proces. În articolul următor vom rafina cazurile de utilizare și vom trece la partea de implementare.
de Ovidiu Mățan
de Ovidiu Mățan
de Ovidiu Mățan
de Sorina Mone