5 rzeczy, których nigdy nie robiłeś ze specyfikacją REST
Opublikowany: 2022-03-11Większość programistów front-end i back-end miała już do czynienia ze specyfikacjami REST i interfejsami API RESTful. Ale nie wszystkie interfejsy API RESTful są sobie równe. W rzeczywistości rzadko są pełne REST…
Co to jest RESTful API?
To mit.
Jeśli uważasz, że Twój projekt ma RESTful API, najprawdopodobniej się mylisz. Ideą RESTful API jest programowanie w sposób zgodny ze wszystkimi zasadami i ograniczeniami architektonicznymi opisanymi w specyfikacji REST. Jednak realistycznie jest to w praktyce w dużej mierze niemożliwe.
Z jednej strony REST zawiera zbyt wiele niejasnych i niejednoznacznych definicji. Na przykład w praktyce niektóre terminy ze słowników metody HTTP i kodu statusu są używane niezgodnie z ich przeznaczeniem lub w ogóle nie są używane.
Z drugiej strony rozwój REST stwarza zbyt wiele ograniczeń. Na przykład użycie zasobów atomowych jest nieoptymalne w przypadku rzeczywistych interfejsów API używanych w aplikacjach mobilnych. Pełna odmowa przechowywania danych między żądaniami zasadniczo blokuje mechanizm „sesji użytkownika”, który można zobaczyć niemal wszędzie.
Ale czekaj, nie jest tak źle!
Do czego potrzebujesz specyfikacji REST API?
Pomimo tych wad, przy rozsądnym podejściu, REST nadal jest niesamowitym pomysłem na tworzenie naprawdę świetnych interfejsów API. Te interfejsy API mogą być spójne i mieć przejrzystą strukturę, dobrą dokumentację i wysokie pokrycie testów jednostkowych. Możesz to wszystko osiągnąć dzięki wysokiej jakości specyfikacji API .
Zazwyczaj specyfikacja REST API jest powiązana z jego dokumentacją . W przeciwieństwie do specyfikacji — formalnego opisu Twojego interfejsu API — dokumentacja ma być czytelna dla człowieka: na przykład może być odczytywana przez programistów aplikacji mobilnej lub internetowej korzystającej z Twojego interfejsu API.
Prawidłowy opis API to nie tylko dobre pisanie dokumentacji API. W tym artykule chcę podzielić się przykładami, jak możesz:
- Spraw, aby Twoje testy jednostkowe były prostsze i bardziej niezawodne;
- Skonfiguruj wstępne przetwarzanie i sprawdzanie poprawności danych wejściowych użytkownika;
- Zautomatyzuj serializację i zapewnij spójność odpowiedzi; i nawet
- Korzystaj z zalet pisania statycznego.
Ale najpierw zacznijmy od wprowadzenia do świata specyfikacji API.
OpenAPI
OpenAPI jest obecnie najszerzej akceptowanym formatem specyfikacji REST API. Specyfikacja jest zapisana w jednym pliku w formacie JSON lub YAML składającym się z trzech sekcji:
- Nagłówek z nazwą API, opisem i wersją, a także wszelkie dodatkowe informacje.
- Opisy wszystkich zasobów, w tym identyfikatory, metody HTTP, wszystkie parametry wejściowe, kody odpowiedzi i typy danych treści wraz z linkami do definicji.
- Wszystkie definicje, których można użyć do wprowadzania lub wyprowadzania danych, w formacie JSON Schema (który, tak, może być również reprezentowany w YAML).
Struktura OpenAPI ma dwie istotne wady: jest zbyt skomplikowana i czasami nadmiarowa. Mały projekt może mieć specyfikację JSON składającą się z tysięcy wierszy. Ręczne utrzymanie tego pliku staje się niemożliwe. Stanowi to poważne zagrożenie dla idei utrzymywania aktualności specyfikacji w trakcie tworzenia API.
Istnieje wiele edytorów, które pozwalają opisać API i wygenerować dane wyjściowe OpenAPI. Dodatkowe usługi i oparte na nich rozwiązania chmurowe to między innymi Swagger, Apiary, Stoplight, Restlet i wiele innych.
Jednak usługi te były dla mnie niewygodne ze względu na złożoność szybkiej edycji specyfikacji i dostosowania jej do zmian w kodzie. Dodatkowo lista funkcji była uzależniona od konkretnej usługi. Na przykład stworzenie pełnoprawnych testów jednostkowych w oparciu o narzędzia usługi w chmurze jest prawie niemożliwe. Generowanie kodu i wyśmiewanie punktów końcowych, choć wydają się praktyczne, w praktyce okazują się w większości bezużyteczne. Dzieje się tak głównie dlatego, że zachowanie punktu końcowego zwykle zależy od różnych rzeczy, takich jak uprawnienia użytkownika i parametry wejściowe, które mogą być oczywiste dla architekta API, ale nie są łatwe do automatycznego wygenerowania na podstawie specyfikacji OpenAPI.
Mała specyfikacja
W tym artykule użyję przykładów opartych na moim własnym formacie definicji API REST, tinyspec . Definicje składają się z małych plików o intuicyjnej składni. Opisują punkty końcowe i modele danych używane w projekcie. Pliki są przechowywane obok kodu, zapewniając szybkie odniesienie i możliwość edycji podczas pisania kodu. Tinyspec jest automatycznie kompilowany do pełnoprawnego formatu OpenAPI, który można natychmiast wykorzystać w projekcie.
Będę również używał przykładów Node.js (Koa, Express) i Ruby on Rails, ale praktyki, które zademonstruję, mają zastosowanie w większości technologii, w tym w Pythonie, PHP i Javie.
Gdzie specyfikacja API rządzi
Teraz, gdy mamy już pewne podstawy, możemy zbadać, jak najlepiej wykorzystać właściwie określony interfejs API.
1. Testy jednostek końcowych
Programowanie sterowane zachowaniem (BDD) jest idealne do tworzenia interfejsów API REST. Najlepiej pisać testy jednostkowe nie dla oddzielnych klas, modeli czy kontrolerów, ale dla poszczególnych punktów końcowych. W każdym teście emulujesz prawdziwe żądanie HTTP i weryfikujesz odpowiedź serwera. Dla Node.js dostępne są pakiety supertest i chai-http do emulacji żądań, a dla Ruby on Rails jest w powietrzu.
Załóżmy, że mamy schemat User
i punkt końcowy GET /users
, który zwraca wszystkich użytkowników. Oto jakaś składnia tinyspec, która to opisuje:
# user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}
A oto jak napisalibyśmy odpowiedni test:
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 on 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
Gdy mamy już specyfikację opisującą odpowiedzi serwera, możemy uprościć test i po prostu sprawdzić, czy odpowiedź jest zgodna ze specyfikacją. Możemy użyć modeli tinyspec, z których każdy można przekształcić w specyfikację OpenAPI zgodną z formatem JSON Schema.
Każdy dosłowny obiekt w JS (lub Hash
w Ruby, dict
w Pythonie, tablica asocjacyjna w PHP, a nawet Map
w Javie) może zostać zweryfikowany pod kątem zgodności ze schematem JSON. Istnieją nawet odpowiednie wtyczki do testowania frameworków, na przykład jest-ajv (npm), chai-ajv-json-schema (npm) i json_matchers dla RSpec (rubygem).
Przed użyciem schematów zaimportujmy je do projektu. Najpierw wygeneruj plik openapi.json
na podstawie specyfikacji tinyspec (możesz to zrobić automatycznie przed każdym uruchomieniem testu):
tinyspec -j -o openapi.json
Node.js
Teraz możesz użyć wygenerowanego JSON w projekcie i uzyskać z niego klucz definitions
. Ten klucz zawiera wszystkie schematy JSON. Schematy mogą zawierać odsyłacze ( $ref
), więc jeśli masz jakieś osadzone schematy (na przykład Blog {posts: Post[]}
), musisz je rozpakować do użycia w walidacji. W tym celu użyjemy 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 on Rails
Moduł json_matchers
wie, jak obsługiwać odwołania $ref
, ale wymaga oddzielnych plików schematu w określonej lokalizacji, więc najpierw musisz podzielić plik swagger.json
na wiele mniejszych plików:
# ./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
Oto jak będzie wyglądał 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
Pisanie testów w ten sposób jest niezwykle wygodne. Zwłaszcza jeśli twoje IDE obsługuje uruchamianie testów i debugowanie (na przykład WebStorm, RubyMine i Visual Studio). W ten sposób możesz uniknąć korzystania z innego oprogramowania, a cały cykl rozwoju API ogranicza się do trzech kroków:
- Projektowanie specyfikacji w plikach tinyspec.
- Napisanie pełnego zestawu testów dla dodanych/edytowanych punktów końcowych.
- Implementacja kodu, który spełnia testy.
2. Walidacja danych wejściowych
OpenAPI opisuje nie tylko format odpowiedzi, ale także dane wejściowe. Pozwala to na weryfikację danych wysłanych przez użytkownika w czasie wykonywania oraz zapewnienie spójnych i bezpiecznych aktualizacji baz danych.
Załóżmy, że mamy następującą specyfikację, która opisuje łatanie rekordu użytkownika i wszystkich dostępnych pól, które można aktualizować:
# user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}
Wcześniej badaliśmy wtyczki do walidacji w trakcie testów, ale w bardziej ogólnych przypadkach istnieją moduły walidacji ajv (npm) i json-schema (rubygem). Wykorzystajmy je do napisania kontrolera z walidacją:
Node.js (Koa)
To jest przykład dla Koa, następcy Express, ale równoważny kod Express będzie wyglądał podobnie.
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; } }
W tym przykładzie serwer zwraca odpowiedź 500 Internal Server Error
, jeśli dane wejściowe nie są zgodne ze specyfikacją. Aby tego uniknąć, możemy złapać błąd walidatora i stworzyć własną odpowiedź, która będzie zawierać bardziej szczegółowe informacje o konkretnych polach, które nie przeszły walidacji, i postępować zgodnie ze specyfikacją.
Dodajmy definicję dla FieldsValidationError
:
# error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}
A teraz wymieńmy to jako jedną z możliwych odpowiedzi punktu końcowego:
# users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError
Takie podejście umożliwia pisanie testów jednostkowych, które testują poprawność scenariuszy błędów, gdy nieprawidłowe dane pochodzą od klienta.
3. Serializacja modelu
Prawie wszystkie nowoczesne frameworki serwerowe wykorzystują mapowanie obiektowo-relacyjne (ORM) w taki czy inny sposób. Oznacza to, że większość zasobów używanych przez interfejs API jest reprezentowana przez modele oraz ich instancje i kolekcje.

Proces tworzenia reprezentacji JSON dla tych jednostek do wysłania w odpowiedzi nazywa się serializacją .
Istnieje wiele wtyczek do serializacji: na przykład sequelize-to-json (npm), act_as_api (rubygem) i jsonapi-rails (rubygem). Zasadniczo wtyczki te umożliwiają dostarczenie listy pól dla konkretnego modelu, które muszą być zawarte w obiekcie JSON, a także dodatkowych reguł. Na przykład możesz zmieniać nazwy pól i dynamicznie obliczać ich wartości.
Staje się to trudniejsze, gdy potrzebujesz kilku różnych reprezentacji JSON dla jednego modelu lub gdy obiekt zawiera zagnieżdżone jednostki — powiązania. Następnie zaczynasz potrzebować funkcji, takich jak dziedziczenie, ponowne użycie i łączenie serializatora.
Różne moduły zapewniają różne rozwiązania, ale zastanówmy się: czy specyfikacja może znowu pomóc? W zasadzie wszystkie informacje o wymaganiach dla reprezentacji JSON, wszystkie możliwe kombinacje pól, w tym encje osadzone, już się w nim znajdują. A to oznacza, że możemy napisać jeden zautomatyzowany serializator.
Pozwólcie, że przedstawię mały moduł sequelize-serialize (npm), który obsługuje to dla modeli Sequelize. Akceptuje wystąpienie modelu lub tablicę oraz wymagany schemat, a następnie wykonuje iterację w celu skompilowania obiektu serializowanego. Uwzględnia również wszystkie wymagane pola i używa zagnieżdżonych schematów dla powiązanych z nimi jednostek.
Załóżmy więc, że musimy zwrócić wszystkich użytkowników z postami na blogu, w tym komentarze do tych postów, z interfejsu API. Opiszmy to poniższą specyfikacją:
# 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[]}
Teraz możemy zbudować żądanie za pomocą Sequelize i zwrócić zserializowany obiekt, który dokładnie odpowiada specyfikacji opisanej powyżej:
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); });
To prawie magiczne, prawda?
4. Pisanie statyczne
Jeśli jesteś wystarczająco fajny, aby używać TypeScript lub Flow, być może już zadałeś pytanie „Co z moimi cennymi typami statycznymi?!” Dzięki modułom sw2dts lub swagger-to-flowtype można generować wszystkie niezbędne typy statyczne na podstawie schematów JSON i używać ich w testach, kontrolerach i serializatorach.
tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api
Teraz możemy używać typów w kontrolerach:
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 }; });
Oraz testy:
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); });
Należy zauważyć, że wygenerowane definicje typów mogą być używane nie tylko w projekcie API, ale także w projektach aplikacji klienckich do opisywania typów w funkcjach współpracujących z API. (Programiści Angular będą z tego szczególnie zadowoleni.)
5. Rzucanie typów ciągów zapytań
Jeśli Twój interfejs API z jakiegoś powodu zużywa żądania z typem MIME application/x-www-form-urlencoded
zamiast application/json
, treść żądania będzie wyglądać tak:
param1=value¶m2=777¶m3=false
To samo dotyczy parametrów zapytania (na przykład w żądaniach GET
). W takim przypadku serwer WWW nie rozpozna automatycznie typów: Wszystkie dane będą w formacie ciągu, więc po parsowaniu otrzymasz ten obiekt:
{ param1: 'value', param2: '777', param3: 'false' }
W takim przypadku żądanie nie powiedzie się weryfikacja schematu, więc musisz ręcznie zweryfikować poprawne formaty parametrów i rzutować je na właściwe typy.
Jak łatwo się domyślić, można to zrobić za pomocą naszych starych, dobrych schematów ze specyfikacji. Załóżmy, że mamy ten punkt końcowy i następujący schemat:
# posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }
Oto jak wygląda żądanie do tego punktu końcowego:
GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true
Napiszmy funkcję castQuery
, która rzutuje wszystkie parametry na wymagane typy:
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; } }); }
Pełniejsza implementacja z obsługą zagnieżdżonych schematów, tablic i typów null
jest dostępna w module cast-with-schema (npm). Teraz użyjmy go w naszym kodzie:
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) }; });
Zauważ, że trzy z czterech wierszy kodu używają schematów specyfikacji.
Najlepsze praktyki
Istnieje kilka najlepszych praktyk, które możemy tutaj zastosować.
Użyj oddzielnych schematów tworzenia i edycji
Zazwyczaj schematy opisujące odpowiedzi serwera różnią się od tych, które opisują dane wejściowe i są używane do tworzenia i edycji modeli. Na przykład lista pól dostępnych w żądaniach POST
i PATCH
musi być ściśle ograniczona, a PATCH
zwykle ma wszystkie pola oznaczone jako opcjonalne. Schematy opisujące odpowiedź mogą być bardziej dowolne.
Kiedy automatycznie generujesz punkty końcowe CRUDL, tinyspec używa przyrostków New
i Update
. Schematy User*
można zdefiniować w następujący sposób:
User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}
Staraj się nie używać tych samych schematów dla różnych typów akcji, aby uniknąć przypadkowych problemów z bezpieczeństwem spowodowanych ponownym użyciem lub dziedziczeniem starszych schematów.
Postępuj zgodnie z konwencjami nazewnictwa schematów
Zawartość tych samych modeli może się różnić dla różnych punktów końcowych. Użyj przyrostków With*
i For*
w nazwach schematów, aby pokazać różnicę i cel. W tinyspec modele mogą również dziedziczyć po sobie. Na przykład:
User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}
Przyrostki można zmieniać i łączyć. Ich nazwa musi nadal odzwierciedlać istotę i ułatwiać czytanie dokumentacji.
Rozdzielanie punktów końcowych na podstawie typu klienta
Często ten sam punkt końcowy zwraca różne dane w zależności od typu klienta lub roli użytkownika, który wysłał żądanie. Na przykład punkty końcowe GET /users
i GET /messages
mogą się znacznie różnić dla użytkowników aplikacji mobilnych i menedżerów zaplecza. Zmiana nazwy punktu końcowego może być kosztowna.
Aby opisać ten sam punkt końcowy wielokrotnie, możesz dodać jego typ w nawiasach po ścieżce. Ułatwia to również korzystanie z tagów: dzielisz dokumentację punktów końcowych na grupy, z których każda jest przeznaczona dla określonej grupy klientów interfejsu API. Na przykład:
Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]
Narzędzia dokumentacji interfejsu API REST
Po otrzymaniu specyfikacji w formacie tinyspec lub OpenAPI możesz wygenerować ładnie wyglądającą dokumentację w formacie HTML i opublikować ją. To sprawi, że programiści korzystający z Twojego interfejsu API będą zadowoleni, a ręczne wypełnianie szablonu dokumentacji interfejsu API REST jest lepsze.
Oprócz wspomnianych wcześniej usług w chmurze istnieją narzędzia CLI, które konwertują OpenAPI 2.0 na HTML i PDF, które można wdrożyć na dowolnym statycznym hostingu. Oto kilka przykładów:
- bootprint-openapi (npm, domyślnie używane w tinyspec)
- swagger2markup-cli (jar, jest przykład użycia, będzie używany w tinyspec Cloud)
- redoc-cli (npm)
- widdershins (npm)
Masz więcej przykładów? Podziel się nimi w komentarzach.
Niestety, pomimo wydania rok temu, OpenAPI 3.0 jest nadal słabo wspierany i nie udało mi się znaleźć odpowiednich przykładów dokumentacji na jego podstawie zarówno w rozwiązaniach chmurowych, jak i w narzędziach CLI. Z tego samego powodu tinyspec nie obsługuje jeszcze OpenAPI 3.0.
Publikowanie na GitHub
Jednym z najprostszych sposobów publikowania dokumentacji jest GitHub Pages. Wystarczy włączyć obsługę stron statycznych dla folderu /docs
w ustawieniach repozytorium i przechowywać dokumentację HTML w tym folderze.
Możesz dodać polecenie, aby wygenerować dokumentację za pomocą tinyspec lub innego narzędzia CLI w pliku scripts/package.json
, aby automatycznie aktualizować dokumentację po każdym zatwierdzeniu:
"scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }
Ciągła integracja
Możesz dodać generację dokumentacji do swojego cyklu CI i opublikować ją na przykład w Amazon S3 pod różnymi adresami w zależności od środowiska lub wersji API (np. /docs/2.0
, /docs/stable
i /docs/staging
).
Mała chmura
Jeśli podoba Ci się składnia tinyspec, możesz zostać wczesnym użytkownikiem tinyspec.cloud. Planujemy na jej podstawie zbudować usługę w chmurze oraz CLI do automatycznego wdrażania dokumentacji z szerokim wyborem szablonów i możliwością tworzenia szablonów spersonalizowanych.
Specyfikacja REST: Cudowny mit
Tworzenie REST API to prawdopodobnie jeden z najprzyjemniejszych procesów w rozwoju nowoczesnych usług webowych i mobilnych. Nie ma przeglądarki, systemu operacyjnego ani ogrodów zoologicznych wielkości ekranu, a wszystko jest pod Twoją kontrolą, na wyciągnięcie ręki.
Proces ten jest jeszcze łatwiejszy dzięki wsparciu automatyzacji i aktualnym specyfikacjom. API wykorzystujące opisane przeze mnie podejścia staje się dobrze ustrukturyzowane, przejrzyste i niezawodne.
Najważniejsze jest to, że jeśli tworzymy mit, dlaczego nie uczynić go cudownym mitem?