Урок 33. REST: принципи дизайну API¶
В уроці 32 ми були клієнтом: надсилали запити до чужого API і розбирались із його відповідями. Сьогодні — інша роль: ми проєктуємо API, яким користуватимуться інші.
Приклад — справжній проєкт викладача. Кожні три години метеостанції України передають синоптичні телеграми — короткі рядки цифр на кшталт AAXX 02181 34504 32975 51106 10251 …. Сервіс завантажує їх із сайту ogimet.com, розкодовує в температуру, тиск і вітер, зберігає в базі й віддає через API: для Streamlit-дашборду, для аналізу в pandas, для інших програм.
Перша версія цього API працює. Але подивимось на її адреси: /download_telegrams, POST /filter_telegrams/, /telegram/ua/3450420249218 — і на відповідь «даних немає» з кодом 200 OK. Клієнтам таким API користуватися важко. Сьогодні розберемо, як це зробити правильно (REST), перепроєктуємо API у версію 2 і побудуємо до неї карту погоди. А ще подивимось, якими бувають API взагалі: RPC, GraphQL, gRPC, SOAP, WebSocket, webhook.
Що потрібно з попередніх уроків: класи (урок 20), pytest (урок 26), репозиторій (урок 30), HTTP-методи й статус-коди, requests, клієнт-клас (урок 32).
Після уроку ти зможеш:
- розрізнити типи API — REST, RPC, GraphQL, gRPC, SOAP, WebSocket, SSE, webhook — і обрати тип під задачу;
- спроєктувати REST API: ресурси-іменники, методи за призначенням, чесні статус-коди, формат помилок;
- додати фільтри, вибір полів і пагінацію;
- обробити довгу операцію через
202 Accepted; - знайти порушення REST у чужому API і запропонувати виправлення;
- написати клієнт до свого API й дашборд поверх нього.
Задача розділу. Meteo API v2 і Streamlit-карта погоди. Повний приклад — у розділі «Практика».
Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки — сервер запускається прямо в ноутбуці.
Пригадай¶
- Який метод ідемпотентний:
POSTчиDELETE? Що це означає (урок 32)? - Чим відповідь
404відрізняється від422? - Навіщо клієнт-клас (
SmachnoClient), якщо єrequests?
Відповіді
DELETE: повторний запит лишає сервер у тому самому стані — ресурсу немає.POSTпри повторі створює ще один ресурс.404— такого ресурсу немає.422— запит зрозумілий, але дані не пройшли перевірку (наприклад, від'ємна сума).- Адреса, тайм-аут, повтори й перетворення кодів на винятки — в одному місці, а не в кожному виклику.
Метеотелеграма: які дані віддає API¶
Уся робота — у папці уроку lesson_33_rest_api_design. Встанови залежності (у Colab це робить ноутбук):
pip install fastapi uvicorn pymetdecoder strawberry-graphql grpcio grpcio-tools websockets httpx requests
Телеграма SYNOP (код КН-01) — рядок груп по п'ять цифр. Розкодовує її бібліотека pymetdecoder; обгортка з проєкту викладача — у meteo_api/decoder.py:
from meteo_api.decoder import decode_synop
telegram = "AAXX 02181 34504 32975 51106 10251 20129 39989 40151 52027 80001 333 10330"
for key, value in decode_synop(telegram).items():
print(f"{key:19} {value}")
temperature 25.1
dew_point 12.9
relative_humidity 47
wind_dir 110
wind_speed 6
pressure 998.9
sea_level_pressure 1015.1
max_temperature 33.0
min_temperature None
| Група | Що означає |
|---|---|
AAXX |
телеграма з наземної станції |
02181 |
день 02, строк 18 UTC, вітер у м/с (1) |
34504 |
номер станції за каталогом WMO: 34 — район, 504 — Дніпро |
51106 |
хмарність, вітер: напрямок 11 → 110°, швидкість 06 м/с |
10251 |
1 — температура, 0 — плюс, 251 → 25.1 °C |
20129 |
2 — точка роси: 12.9 °C |
39989 |
3 — тиск на станції: 998.9 гПа |
40151 |
4 — тиск на рівні моря: 1015.1 гПа |
333 10330 |
розділ 3: максимальна температура за день 33.0 °C |
Відносну вологість (47 %) телеграма не передає — її обчислюємо з температури й точки роси.
Ця телеграма — справжня: станція Дніпро, 2 вересня 2024 року, 18:00 UTC, з документації API викладача. Вона ж — фікстура для тестів, і поки що API працює саме на ній. Знімок телеграм 12 обласних центрів за кілька діб робить скрипт meteo_api/fetch_snapshot.py (код викладача, що завантажує телеграми з ogimet.com): щойно файли знімка з'являться в meteo_api/data/, API підхопить їх автоматично.
Які бувають API¶
API (Application Programming Interface) — домовленість, як одна програма просить іншу щось зробити. Функції модуля math — теж API: бібліотечний, у межах однієї програми. Сьогодні говоримо про мережеві API, де програми — на різних комп'ютерах.
Запустимо навчальний сервер: він віддає ті самі метеодані через усі типи API одразу.
| Тип | Хто починає розмову | Формат | Контракт | Де зустрінеш |
|---|---|---|---|---|
| HTTP + файл (CSV) | клієнт | CSV, текст | документація сайту | ogimet, старі державні й наукові сервіси |
| REST | клієнт | JSON | OpenAPI (необов'язково) | більшість публічних API: GitHub, Stripe, Telegram Bot API |
| JSON-RPC | клієнт | JSON | список методів | блокчейн-вузли, LSP (редактори коду) |
| GraphQL | клієнт | JSON, мова запитів | схема (обов'язково) | GitHub API v4, Shopify, мобільні застосунки |
| gRPC | клієнт; можливі потоки в обидва боки | Protobuf (двійковий) | .proto (обов'язково) |
зв'язок мікросервісів усередині компанії |
| SOAP | клієнт | XML-конверт | WSDL (обов'язково) | банки, державні реєстри, старі корпоративні системи |
| WebSocket | будь-хто, канал відкритий | будь-який, частіше JSON | домовленість | чати, біржові котирування, ігри (урок 46) |
| SSE | сервер після запиту клієнта | текстові події | домовленість | стрічки новин, прогрес задач, відповіді LLM по слову |
| Webhook | сервер — сам дзвонить клієнту | JSON | документація | оплати (Stripe), GitHub, Telegram-боти (урок 48) |
HTTP + CSV: так працює ogimet¶
Сайт ogimet.com віддає телеграми за звичайним GET-запитом з параметрами — без JSON і без ресурсів. Наш сервер має двійника цього ендпоінта з тим самим форматом:
import requests
params = {"block": "34504", "begin": "202409020000", "end": "202409022359"}
response = requests.get(f"{BASE}/cgi-bin/getsynop", params=params, timeout=5)
print(response.headers["Content-Type"])
print(response.text)
text/plain; charset=utf-8
34504,2024,09,02,18,00,AAXX 02181 34504 32975 51106 10251 20129 39989 40151 52027 80001 333 10330=
Це теж API: адреса, параметри, формат відповіді. Але клієнт має знати формат рядка напам'ять: немає назв полів, немає статус-кодів для «станції немає» — лише порожня відповідь. Так виглядають багато старих наукових і державних сервісів. Тому проєкт викладача й загортає ogimet у власний API.
RPC: виклик функції через мережу¶
RPC (Remote Procedure Call) — «виклич функцію на іншому комп'ютері». Один URL, а назва методу й аргументи — у тілі запиту. Найпростіший стандарт — JSON-RPC 2.0:
call = {"jsonrpc": "2.0", "method": "temperature.latest", "params": {"wmo": "34504"}, "id": 1}
print(requests.post(f"{BASE}/rpc", json=call, timeout=5).json())
call = {"jsonrpc": "2.0", "method": "temperature.forecast", "params": {"wmo": "34504"}, "id": 2}
print(requests.post(f"{BASE}/rpc", json=call, timeout=5).json())
{'jsonrpc': '2.0', 'result': {'time': '2024-09-02T18:00Z', 'temperature': 25.1}, 'id': 1}
{'jsonrpc': '2.0', 'error': {'code': -32601, 'message': "метод 'temperature.forecast' не існує"}, 'id': 2}
- Усі виклики —
POST /rpc, статус HTTP завжди 200; помилка — в поліerrorз кодом зі стандарту (-32601— «метод не існує»). - RPC природний, коли операція — справді дія, а не ресурс: «перерахувати», «надіслати», «запустити». Але кеші, проксі й браузер не знають, що
temperature.latestлише читає дані: для них це простоPOST.
GraphQL: клієнт сам обирає поля¶
GraphQL — мова запитів до API. Один URL /graphql; клієнт описує, які саме поля і які пов'язані дані хоче, — і отримує рівно це, одним запитом:
query = """
{
station(wmo: "34504") {
name
observations(last: 1) { time temperature }
}
}
"""
print(requests.post(f"{BASE}/graphql", json={"query": query}, timeout=5).json())
{'data': {'station': {'name': 'Дніпро', 'observations': [{'time': '2024-09-02T18:00Z', 'temperature': 25.1}]}}}
- Відповідь повторює форму запиту:
station → name, observations → time, temperature. Тиску немає, бо ми його не просили. - У REST для цього знадобилося б два запити (станція, потім спостереження). GraphQL зручний мобільним застосункам: менше запитів і менше зайвих байтів.
- Платимо складністю: сервер має схему типів, а один «важкий» запит може навантажити базу. Кешувати GraphQL важче, ніж
GET.
SOAP: XML-конверт¶
SOAP — старший за REST стандарт (кінець 1990-х). Запит і відповідь — XML-«конверт», а контракт описаний у файлі WSDL. Досі живе в банках і державних реєстрах:
envelope = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="urn:meteo">
<soap:Body>
<m:GetLatestTemperature><m:Station>34504</m:Station></m:GetLatestTemperature>
</soap:Body>
</soap:Envelope>"""
response = requests.post(f"{BASE}/soap", data=envelope.encode(), timeout=5,
headers={"Content-Type": "text/xml; charset=utf-8"})
print(response.status_code)
print(response.text)
200
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="urn:meteo"><soap:Body><m:GetLatestTemperatureResponse><m:Station>34504</m:Station><m:Time>2024-09-02T18:00Z</m:Time><m:Temperature>25.1</m:Temperature></m:GetLatestTemperatureResponse></soap:Body></soap:Envelope>
Той самий виклик «дай температуру», що й у JSON-RPC, але в кілька разів довший. Для SOAP є бібліотека zeep: вона читає WSDL і створює Python-методи, щоб XML не писати руками.
gRPC: двійковий контракт¶
gRPC — RPC від Google поверх HTTP/2. Контракт пишуть у файлі .proto:
service Meteo {
rpc GetLatest (StationRequest) returns (Observation);
rpc StreamObservations (StationRequest) returns (stream Observation);
}
message StationRequest { string wmo = 1; }
message Observation { string station = 1; string time = 2; double temperature = 3; double pressure = 4; }
З нього grpcio-tools генерує класи і для сервера, і для клієнта. Клієнт викликає методи сервера як звичайні функції:
import grpc
from meteo_api.grpc_meteo import connect, start_grpc_server
stub, pb2 = connect(start_grpc_server())
reply = stub.GetLatest(pb2.StationRequest(wmo="34504"), timeout=5)
print(type(reply).__name__, reply.station, reply.time, reply.temperature)
print(len(reply.SerializeToString()), "байтів у двійковому вигляді")
try:
stub.GetLatest(pb2.StationRequest(wmo="99999"), timeout=5)
except grpc.RpcError as error:
print(error.code(), error.details())
Observation 34504 2024-09-02T18:00Z 25.1
44 байтів у двійковому вигляді
StatusCode.NOT_FOUND станцію 99999 не знайдено
- Повідомлення — двійкові (Protocol Buffers): коротші за JSON і розбираються швидше. Людині їх не прочитати — для налагодження потрібні спеціальні інструменти.
- Помилки — власні коди gRPC (
NOT_FOUND,UNAVAILABLE…), а не HTTP-статуси. - З браузера gRPC напряму не викликати. Тому його обирають для зв'язку сервісів між собою, а не для публічних API.
WebSocket і SSE: сервер надсилає сам¶
У всіх попередніх типах розмову починає клієнт: «запитав — отримав». А якщо клієнт хоче дізнатися про нове спостереження одразу, як воно з'явиться? Питати сервер щосекунди (polling) — марна робота для обох.
WebSocket — постійний двосторонній канал: після рукостискання по HTTP з'єднання лишається відкритим, і писати в нього може будь-яка сторона.
import asyncio
import json
import websockets
async def listen(wmo):
async with websockets.connect(BASE.replace("http", "ws") + "/ws/observations") as ws:
await ws.send(json.dumps({"subscribe": wmo}))
async for message in ws:
print("отримано:", message)
asyncio.run(listen("34504"))
SSE (Server-Sent Events) — простіше: звичайна HTTP-відповідь з типом text/event-stream, яку сервер не закриває й дописує подію за подією. Канал односторонній — лише від сервера. Так ChatGPT і Claude показують відповідь по словах.
import httpx
with httpx.stream("GET", f"{BASE}/events/observations", params={"station": "34504"}, timeout=5) as response:
print(response.headers["content-type"])
for line in response.iter_lines():
print(repr(line))
text/event-stream; charset=utf-8
'event: observation'
'data: {"time": "2024-09-02T18:00Z", "temperature": 25.1}'
''
'event: done'
'data: {}'
''
Webhook: «не дзвоніть нам — ми подзвонимо»¶
Webhook перевертає ролі: клієнт один раз реєструє свою адресу, а сервер сам робить POST на неї, коли стається подія. Так платіжні системи повідомляють про оплату, а GitHub — про новий коміт.
Для прикладу піднімемо маленький «приймач» — сервер одержувача на порту 8099:
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
received = []
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
received.append((self.headers["X-Meteo-Signature"], body))
self.send_response(204)
self.end_headers()
def log_message(self, *args):
pass
threading.Thread(target=HTTPServer(("127.0.0.1", 8099), Receiver).serve_forever, daemon=True).start()
print(requests.post(f"{BASE}/webhooks", json={"url": "http://127.0.0.1:8099/meteo"}, timeout=5).json())
print(requests.post(f"{BASE}/webhooks/test-event", json={"station": "34504"}, timeout=5).json())
signature, body = received[0]
print(body.decode())
{'url': 'http://127.0.0.1:8099/meteo', 'events': ['observation.created']}
{'delivered': 1, 'subscribers': 1}
{"event": "observation.created", "station": "34504", "time": "2024-09-02T18:00Z", "temperature": 25.1}
Адресу одержувача знає кожен, хто її побачив, тому будь-хто може надіслати туди підробку. Захист — підпис: сервер рахує HMAC-SHA256 від тіла зі спільним секретом (урок 17) і кладе його в заголовок. Одержувач перераховує підпис сам:
import hashlib
import hmac
from meteo_api.api_types import WEBHOOK_SECRET
expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
print("підпис справжній:", hmac.compare_digest(signature, expected))
print("підробка пройде:", hmac.compare_digest(signature, hmac.new(b"guess", body, hashlib.sha256).hexdigest()))
hmac.compare_digest, а не ==: звичайне порівняння зупиняється на першій різниці, і за часом відповіді можна підбирати підпис посимвольно.
sequenceDiagram
participant C as клієнт
participant S as сервер
Note over C,S: polling — клієнт питає щоразу
C->>S: GET /observations/latest
S-->>C: нічого нового
C->>S: GET /observations/latest
S-->>C: нове спостереження
Note over C,S: WebSocket / SSE — канал відкритий
C->>S: підписуюсь на 34504
S-->>C: спостереження 18:00
S-->>C: спостереження 21:00
Note over C,S: webhook — сервер дзвонить сам
C->>S: POST /webhooks {url}
S->>C: POST url: нове спостереження + підпис
C-->>S: 204
Як обрати тип API¶
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
Q["новий API"] --> P{"сервер має повідомляти<br>про події сам?"}
P -- "так, іншому серверу" --> WH["webhook"]
P -- "так, у браузер" --> BI{"писати мають<br>обидві сторони?"}
BI -- так --> WS["WebSocket"]
BI -- "ні, лише сервер" --> SSE["SSE"]
P -- ні --> WHO{"хто клієнти?"}
WHO -- "свої сервіси,<br>важлива швидкість" --> G["gRPC"]
WHO -- "фронтенд з різними<br>потребами в полях" --> GQ["GraphQL"]
WHO -- "будь-хто,<br>публічний API" --> R["REST"]
WHO -- "стара система<br>вимагає XML" --> SO["SOAP"]
class Q step
class P,BI,WHO decision
class R success
class WH,WS,SSE,G,GQ step
class SO warning
Для публічного API і для більшості вебзастосунків вибір за замовчуванням — REST: його розуміють усі інструменти, від браузера до curl. Решта цього уроку — про REST.
REST: принципи¶
REST (Representational State Transfer) — архітектурний стиль, який описав Рой Філдінг у 2000 році у своїй дисертації. Не протокол і не бібліотека, а набір обмежень. Головні з них:
| Обмеження | Що означає для API |
|---|---|
| Ресурси | API — це набір «речей» з адресами: станції, спостереження. URL — іменник, дія — метод HTTP |
| Єдиний інтерфейс | однакові правила для всіх ресурсів: ті самі методи, ті самі коди, той самий формат помилок |
| Представлення | клієнт отримує не сам об'єкт з бази, а його представлення — JSON |
| Stateless | кожен запит несе все потрібне: сервер не пам'ятає попередніх (урок 32). Токен — у кожному запиті |
| Кешованість | GET можна кешувати — проксі, браузер, CDN; POST — ні |
| Шари | клієнт не знає, чи відповідає сам сервер, балансувальник, чи кеш (nginx — урок 50) |
Ресурси й URL¶
Ресурси Meteo API v2 утворюють дерево: станція містить свої спостереження.
graph TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
ROOT["/api/v1"] --> ST["/stations<br>колекція станцій"]
ST --> S1["/stations/34504<br>одна станція"]
S1 --> OB["/stations/34504/observations<br>колекція спостережень"]
OB --> O1["/stations/34504/observations/2024-09-02T18:00Z<br>одне спостереження"]
ROOT --> LA["/observations/latest<br>останні по всіх станціях"]
ROOT --> IM["/imports<br>задачі імпорту"]
class ROOT step
class ST,OB,IM decision
class S1,O1 success
class LA warning
Правила адрес:
- іменники в множині:
/stations, а не/getStationsчи/station_list; - ідентифікатор — у шляху:
/stations/34504; фільтри — у параметрах:?hour=18; - вкладеність показує належність: спостереження живуть «всередині» станції. Глибше двох рівнів не йдемо;
- ідентифікатор читається:
2024-09-02T18:00Z(формат ISO 8601), а не склеєне3450420249218; - версія на початку:
/api/v1/…. Коли зміни зламають старих клієнтів, з'явиться/api/v2/…, аv1ще якийсь час житиме.
Методи¶
| Запит | Що робить | Ідемпотентний |
|---|---|---|
GET /stations |
список станцій | так |
GET /stations/34504 |
одна станція | так |
GET /stations/34504/observations |
спостереження станції | так |
POST /stations/34504/observations |
додати спостереження | ні |
PATCH …/observations/2024-09-02T18:00Z |
виправити частину полів | так (у нашому API) |
DELETE …/observations/2024-09-02T18:00Z |
видалити | так |
Прочитаємо станцію і одне спостереження:
print(requests.get(f"{BASE}/api/v1/stations/34504", timeout=5).json())
obs = requests.get(f"{BASE}/api/v1/stations/34504/observations/2024-09-02T18:00Z", timeout=5).json()
print(obs["temperature"], obs["pressure"], obs["wind_speed"])
Статус-коди і формат помилок¶
Код відповіді — перше, що дивиться клієнт (урок 32: raise_for_status). Тому код має казати правду.
Створимо умовне спостереження (значення вигадані для прикладу — наприкінці розділу ми його видалимо), спробуємо створити його вдруге, надішлемо неправильні дані й попросимо неіснуючу станцію:
url = f"{BASE}/api/v1/stations/34504/observations"
new = {"time": "2024-09-02T21:00Z", "temperature": 21.4, "pressure": 999.6}
created = requests.post(url, json=new, timeout=5)
print(created.status_code, created.headers["Location"])
print(created.json()["temperature"])
again = requests.post(url, json=new, timeout=5)
print(again.status_code, again.json())
bad = requests.post(url, json={"time": "2024-09-03T00:00Z", "temperature": 95}, timeout=5)
print(bad.status_code, bad.json()["detail"][0]["loc"], bad.json()["detail"][0]["msg"])
missing = requests.get(f"{BASE}/api/v1/stations/99999", timeout=5)
print(missing.status_code, missing.json())
201 /api/v1/stations/34504/observations/2024-09-02T21:00Z
21.4
409 {'detail': 'спостереження 34504 на 2024-09-02T21:00Z вже є'}
422 ['body', 'temperature'] Input should be less than or equal to 60
404 {'detail': 'станцію 99999 не знайдено'}
201 Created+ заголовокLocation— адреса нового ресурсу: клієнту не треба її вгадувати.409 Conflict— спостереження на цей строк уже є. ПовторнийPOSTне створив дубль.422— дані не пройшли перевірку: FastAPI сам перевіряє тіло за моделлю Pydantic (урок 37) і пояснює, яке поле і чому.404— станції немає. Тіло помилки завжди має однакову форму{"detail": …}: клієнт розбирає її одним кодом.
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
A["запит прийшов"] --> B{"хто клієнт —<br>відомо?"}
B -- ні --> E401["401"]
B -- так --> C{"має право?"}
C -- ні --> E403["403"]
C -- так --> D{"ресурс існує?"}
D -- ні --> E404["404"]
D -- так --> F{"дані правильні?"}
F -- ні --> E422["422 / 400"]
F -- так --> G{"не суперечить<br>поточному стану?"}
G -- ні --> E409["409"]
G -- так --> H{"що зроблено?"}
H -- "прочитано / змінено" --> S200["200"]
H -- "створено" --> S201["201 + Location"]
H -- "видалено, тіла немає" --> S204["204"]
H -- "прийнято, виконується" --> S202["202 + Location"]
class A step
class B,C,D,F,G,H decision
class E401,E403,E404,E422,E409 error
class S200,S201,S204 success
class S202 warning
PATCH, PUT і DELETE¶
PATCH змінює лише передані поля; PUT замінює ресурс цілком — поля, яких немає в тілі, зникнуть. Для виправлення однієї температури правильний метод — PATCH:
url = f"{BASE}/api/v1/stations/34504/observations/2024-09-02T21:00Z"
fixed = requests.patch(url, json={"temperature": 21.1}, timeout=5).json()
print(fixed["temperature"], fixed["pressure"])
print(requests.patch(url, json={"temperature": -300}, timeout=5).status_code)
first = requests.delete(url, timeout=5)
second = requests.delete(url, timeout=5)
print(first.status_code, repr(first.text), "|", second.status_code, second.json())
- Тиск
999.6залишився:PATCHторкнувся лише температури. DELETEідемпотентний за станом сервера: після першого й другого запиту спостереження однаково немає. Код відповіді при цьому може відрізнятися:204 No Content, потім404.
Фільтри, поля і пагінація¶
Колекція може бути великою: 12 станцій × 8 строків × 365 днів — понад 35 тисяч спостережень за рік. Віддавати все одним шматком не можна: відповідь довга, клієнт чекає, пам'ять сервера забивається. Тому колекції віддають сторінками, а клієнт може попросити лише потрібні поля.
Щоб було що гортати, додамо умовну станцію «Навчальна» з вісьмома спостереженнями за добу (значення вигадані для прикладу). Станцію додаємо прямо в сховище — у нашому API немає POST /stations, — а спостереження вже через API:
from meteo_api.app import app
app.state.repo.add_station("99001", "Навчальна")
url = f"{BASE}/api/v1/stations/99001/observations"
for hour, temperature in zip(range(0, 24, 3), [11.2, 10.4, 12.9, 17.5, 20.1, 19.3, 15.8, 13.0]):
requests.post(url, json={"time": f"2026-09-20T{hour:02d}:00Z", "temperature": temperature}, timeout=5)
page = requests.get(url, params={"fields": "time,temperature", "limit": 3}, timeout=5).json()
print("total:", page["total"], "| limit:", page["limit"], "| offset:", page["offset"])
for item in page["items"]:
print(item)
print("next:", page["next"])
print(requests.get(url, params={"fields": "time,colour"}, timeout=5).json())
total: 8 | limit: 3 | offset: 0
{'time': '2026-09-20T00:00Z', 'temperature': 11.2}
{'time': '2026-09-20T03:00Z', 'temperature': 10.4}
{'time': '2026-09-20T06:00Z', 'temperature': 12.9}
next: http://127.0.0.1:8032/api/v1/stations/99001/observations?fields=time%2Ctemperature&offset=3&limit=3
{'detail': 'невідомі поля: colour'}
Конверт сторінки: items — дані, total — скільки всього, next — готове посилання на наступну сторінку або None, якщо сторінка остання. Невідоме поле у fields — помилка клієнта 400, а не мовчазне ігнорування. Клієнт іде за next, поки воно не стане None, — так робить MeteoClient у розділі «Архітектура»:
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
subgraph P1["запит 1: offset = 0, limit = 3"]
direction LR
A1["GET …/observations<br>?limit=3"] --> B1["items: 00, 03, 06 год<br>total: 8"] --> C1["next:<br>offset=3"]
end
subgraph P2["запит 2: offset = 3"]
direction LR
A2["GET за посиланням next"] --> B2["items: 09, 12, 15 год"] --> C2["next:<br>offset=6"]
end
subgraph P3["запит 3: offset = 6"]
direction LR
A3["GET за посиланням next"] --> B3["items: 18, 21 год"] --> C3["next: None<br>кінець"]
end
P1 --> P2 --> P3
class A1,A2,A3 step
class B1,B2,B3 warning
class C1,C2 decision
class C3 success
params = {"fields": "time", "limit": 3}
while url:
page = requests.get(url, params=params, timeout=5).json()
print(page["offset"], [item["time"][11:16] for item in page["items"]], "next:", page["next"] is not None)
url, params = page["next"], None
0 ['00:00', '03:00', '06:00'] next: True
3 ['09:00', '12:00', '15:00'] next: True
6 ['18:00', '21:00'] next: False
offset / limit — найпростіша пагінація. Великі API (GitHub, Stripe) використовують курсор — «дай 100 записів після запису X»: це стабільніше, коли нові записи з'являються посеред гортання.
Довга операція: 202 Accepted¶
У першій версії POST /download_telegrams завантажував телеграми з ogimet 1–5 хвилин, і весь цей час клієнт чекав відповіді — поки не спрацює тайм-аут. REST-відповідь на довгу роботу — 202 Accepted: «прийняв, виконую». Клієнт отримує адресу задачі й перевіряє її стан пізніше:
import time
csv_line = "34504,2024,09,02,18,00,AAXX 02181 34504 32975 51106 10251 20129 39989 40151 52027 80001 333 10330="
job = requests.post(f"{BASE}/api/v1/imports", json={"csv": csv_line}, timeout=5)
print(job.status_code, job.json()["status"])
status_url = BASE + job.headers["Location"]
while (state := requests.get(status_url, timeout=5).json())["status"] not in ("done", "failed"):
time.sleep(0.1)
print(state["status"], "| додано:", state["added"], "| пропущено:", state["skipped"])
sequenceDiagram
participant C as клієнт
participant A as API
participant W as фонова задача
C->>A: POST /api/v1/imports
A->>W: запустити імпорт
A-->>C: 202 Accepted<br>Location: /api/v1/imports/7f3a
W->>W: розкодувати телеграми
C->>A: GET /api/v1/imports/7f3a
A-->>C: 200 {"status": "running"}
W-->>A: готово: додано 1
C->>A: GET /api/v1/imports/7f3a
A-->>C: 200 {"status": "done", "added": 1}
Імпорт того самого рядка вдруге не створює дубль: спостереження з таким строком просто перезаписується. Тут фонова задача живе в тому самому процесі (BackgroundTasks FastAPI). У production її віддають черзі — Redis з уроку 31 і Celery (уроки 49–50).
OpenAPI: контракт, який пише сам код¶
FastAPI будує опис API за стандартом OpenAPI з самого коду: шляхи, методи, моделі даних. На ньому працює інтерактивна документація /docs (Swagger UI). Подивимось, що в ньому є:
spec = requests.get(f"{BASE}/openapi.json", timeout=5).json()
print(spec["info"]["title"], spec["info"]["version"])
for path, methods in spec["paths"].items():
if path.startswith("/api/v1"):
print(f"{' '.join(m.upper() for m in methods):12} {path}")
Meteo API 2.0.0
GET /api/v1/stations
GET /api/v1/stations/{wmo}
GET POST /api/v1/stations/{wmo}/observations
GET PATCH DELETE /api/v1/stations/{wmo}/observations/{moment}
GET /api/v1/observations/latest
POST /api/v1/imports
GET /api/v1/imports/{job_id}
З цього файла інструменти генерують клієнтів для різних мов, а Postman імпортує всі запити одним кліком. Докладно — в уроці 38.
Кейс: API v1 → v2¶
Порівняймо першу версію API (файл legacy/main.py, код викладача) з версією 2:
| Було (v1) | Проблема | Стало (v2) |
|---|---|---|
POST /filter_telegrams/ з тілом-фільтром |
дієслово в URL; POST для читання — не кешується, не ідемпотентний |
GET /api/v1/stations/{wmo}/observations?hour=18&fields=… |
POST /download_telegrams — чекає хвилини |
клієнт висить до тайм-ауту | POST /api/v1/imports → 202 + адреса статусу |
GET /telegram/ua/3450420249218 |
склеєний id: 2024+9+2+18 — чи це 9+21+8? Ще й назва колекції MongoDB з URL |
GET /api/v1/stations/34504/observations/2024-09-02T18:00Z |
{"message": "Дані за цей період відсутні"} з кодом 200 |
raise_for_status() мовчить, клієнт падає пізніше з KeyError |
404 + {"detail": …} |
{"error": str(e)} з кодом 200 |
помилка сервера виглядає як успіх | виняток → 500, 404, 409, 422 |
PUT з довільним dict |
PUT мав би замінити ресурс цілком; поля без перевірки |
PATCH з моделлю Pydantic: лише дозволені поля, з межами |
DELETE → 200 {"message": …} в обох випадках |
клієнт не відрізнить успіх від «не знайдено» | 204 / 404 |
| увесь результат одним списком | тисячі записів в одній відповіді | limit / offset / total / next |
Друга версія не складніша за першу — вона послідовніша. Кожне рішення вище — одне з правил цього уроку.
Архітектура: API, клієнт і карта¶
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
OG[("ogimet.com<br>телеграми CSV")] -- "fetch_snapshot.py" --> SNAP[("знімок CSV")]
SNAP --> REPO["MeteoRepository<br>storage.py"]
DEC["decode_synop<br>decoder.py"] --> REPO
REPO --> API["Meteo API v2<br>FastAPI, app.py"]
API -- "JSON по HTTP" --> CL["MeteoClient<br>client.py"]
CL --> MAP["карта погоди<br>Streamlit"]
CL --> NB["ноутбук, pandas"]
class OG,SNAP decision
class REPO,DEC step
class API success
class CL warning
class MAP,NB step
- Шари, як в уроці 30.
MeteoRepositoryприховує, звідки дані. Перейти зі знімка CSV на MongoDB чи PostgreSQL — це заміна одного класу, а ендпоінти лишаються тими самими. - API не знає про Streamlit. Карта — лише один із клієнтів. Ноутбук, Telegram-бот чи інший сервіс ходять тими самими запитами.
- Клієнт-клас, як в уроці 32.
MeteoClient(файлweather_map/client.py) знає адресу, тайм-аут, пагінацію і перетворює404наStationNotFound. Streamlit-код не містить жодного URL. - Дані із зовнішнього сайту — знімком. API не ходить в ogimet на кожен запит клієнта: окремий скрипт раз на кілька годин завантажує телеграми. Якщо ogimet «ляже», карта продовжить працювати на останніх даних.
Карта погоди — weather_map/app.py, на основі дашборду викладача: вкладки «Карта» (температура на станціях: синій — мороз, червоний — тепло), «Станція» (графіки температури й тиску) та «HTTP-інспектор» (останній запит клієнта). Запуск — два термінали:
uvicorn meteo_api.app:app --port 8032 # API: http://127.0.0.1:8032/docs
streamlit run weather_map/app.py # карта: http://localhost:8501
Або обидва одразу через Docker Compose (docker-compose.yml; Docker — урок 49):
У Compose карта звертається до API за адресою http://api:8032, а не localhost: кожен контейнер — окремий «комп'ютер» у спільній мережі, а api — ім'я сервісу.
Практика¶
Розібраний приклад: середньодобова температура через API¶
Клієнт викладача TelegramDataLoader завантажував спостереження з API і рахував середньодобові значення в pandas. Зробимо те саме через MeteoClient — з пагінацією і лише потрібними полями:
import sys
import pandas as pd
sys.path.insert(0, "weather_map")
from client import MeteoClient, StationNotFound
client = MeteoClient(BASE)
observations = pd.DataFrame(client.observations("34504", fields="time,temperature"))
observations["time"] = pd.to_datetime(observations["time"])
daily = observations.set_index("time")["temperature"].resample("D").agg(["mean", "min", "max", "count"])
print(daily.round(1))
print(client.last_exchange["request"])
try:
client.observations("99999")
except StationNotFound as error:
print("StationNotFound:", error)
mean min max count
time
2024-09-02 00:00:00+00:00 25.1 25.1 25.1 1
GET http://127.0.0.1:8032/api/v1/stations/34504/observations?fields=time%2Ctemperature&limit=200&offset=0
StationNotFound: станцію 99999 не знайдено
client.observationsсам гортає сторінки: логіка пагінації — в одному місці.fields="time,temperature"— з сервера не йдуть зайві тиск і вітер.resample("D")групує строки по днях (бонусний урок pandas);countпоказує, скільки строків було за добу.- 404 перетворився на
StationNotFound: код аналізу нічого не знає про HTTP.
Зміни приклад: лише денні строки¶
Додай у MeteoClient.observations параметр hour=None і передавай його API як фільтр ?hour=. Порахуй середню температуру лише о 12:00 UTC.
Критерії перевірки:
- без
hourповедінка не змінилася; - з
hour=12усі отримані записи мають час…T12:00Z; - фільтрує сервер, а не pandas: у
client.last_exchange["request"]видноhour=12.
Спробуй самостійно: ресурс «попередження»¶
Спроєктуй (спершу на папері) ресурс попереджень про небезпечну погоду для станції: «спека понад 35 °C», «вітер понад 20 м/с». Попередження створює синоптик, переглядають усі, закриває синоптик.
Критерії перевірки:
- адреси — іменники, вкладені в станцію; є і колекція, і один елемент;
- для кожної дії обрано метод і код успіху; «закрити попередження» — це
PATCHзі статусом, а неPOST /close_alert; - описано 404, 409 (закрити вже закрите) і 422 (невідомий тип попередження);
- список попереджень має фільтр за статусом і пагінацію.
Підказка
GET/POST /api/v1/stations/{wmo}/alerts, GET/PATCH /api/v1/stations/{wmo}/alerts/{id}, фільтр ?status=active. Реалізувати в app.py можна за зразком спостережень.
Знайди помилку¶
Колега додав ендпоінт «середня температура станції»:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from meteo_api.storage import MeteoRepository
repo = MeteoRepository.default()
buggy = FastAPI()
@buggy.post("/get_mean_temperature")
def get_mean_temperature(wmo: str):
try:
items = repo.list_observations(wmo)
return {"mean": sum(o["temperature"] for o in items) / len(items)}
except Exception as e:
return {"error": str(e)}
test = TestClient(buggy)
response = test.post("/get_mean_temperature", params={"wmo": "99999"})
print(response.status_code, response.json())
Знайди три порушення REST.
Відповідь
- Дієслово в URL і
POSTдля читання. Нічого не створюється — цеGETресурсу:GET /api/v1/stations/{wmo}/temperature/meanабо поле вGET /api/v1/stations/{wmo}. - Помилка з кодом 200. Клієнт з
raise_for_status()вирішить, що все добре, і впаде далі наresponse.json()["mean"]зKeyError. Неіснуюча станція —404. except Exceptionховає все. Навіть справжній збій у коді (ділення на нуль для станції без спостережень) стає «успішною» відповіддю. Ловимо лише очікуванийNotFoundError; решту хай FastAPI перетворить на500і запише в лог.
Підсумок¶
| Поняття | Що запам'ятати |
|---|---|
| Типи API | REST, RPC, GraphQL, gRPC, SOAP — клієнт питає; WebSocket, SSE — сервер шле сам; webhook — сервер дзвонить клієнту |
| REST | ресурси з адресами + методи HTTP + чесні коди; stateless, кешованість |
| URL | іменники в множині, id у шляху, фільтри в параметрах, версія /api/v1 |
| Методи | GET читає, POST створює, PATCH змінює частину, PUT замінює цілком, DELETE видаляє |
| Коди | 201 + Location, 204, 202 + Location; 400/422, 401, 403, 404, 409 |
| Помилки | один формат {"detail": …}; ніколи не 200 з {"error": …} |
| Колекції | limit / offset / total / next; fields — лише потрібне |
| Довгі операції | 202 Accepted + адреса задачі, клієнт перевіряє стан |
| OpenAPI | опис API з коду; /docs у FastAPI |
| Webhook | підпис HMAC + hmac.compare_digest |
| Архітектура | репозиторій ↔ API ↔ клієнт-клас ↔ UI; зовнішні дані — знімком |
Самоперевірка¶
- Чим REST відрізняється від RPC? Коли RPC доречніший?
- Чому
POST /filter_telegrams/— погана ідея для пошуку? Як це виправити? - Який код повернути: створено спостереження; таке вже є; температура 95 °C; станції немає; імпорт почався, але не закінчився?
- Навіщо в конверті сторінки поле
next? - Чим
PATCHвідрізняється відPUT? - Коли обрати GraphQL, gRPC, WebSocket, webhook?
- Навіщо підписувати webhook і чому
compare_digest, а не==?
Відповіді
- REST оперує ресурсами (іменник + метод HTTP), RPC — викликами функцій (назва дії в тілі, один URL). RPC доречний, коли операція — справжня дія без очевидного ресурсу, і для внутрішніх сервісів (gRPC).
- Пошук — читання: має бути
GETз параметрами. Тоді він ідемпотентний, кешується, його можна відкрити в браузері й покласти в закладки:GET /api/v1/stations/34504/observations?hour=18. 201+Location;409;422;404;202+ адреса задачі.- Клієнт не рахує
offsetсам, а йде за готовим посиланням. Сервер може змінити спосіб пагінації (наприклад, на курсор), і клієнти не зламаються. PATCHзмінює лише передані поля;PUTзамінює ресурс цілком — поля, яких немає в тілі, зникають.- GraphQL — фронтенду з різними потребами в полях і зв'язаних даних. gRPC — швидкий зв'язок власних сервісів. WebSocket — двосторонній канал у реальному часі (чат). Webhook — повідомити інший сервер про подію без опитування.
- Адресу webhook може дізнатися будь-хто й надіслати підробку; підпис HMAC зі спільним секретом доводить, що лист від справжнього сервера.
==зупиняється на першій різниці, тож за часом відповіді можна підбирати підпис;compare_digestпорівнює за однаковий час.
Що далі¶
- Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки — ресурси, коди, пагінація,
PATCH, GraphQL і клієнт з перевірками. - Наступний урок — 34, «Django intro: MVT, ORM, admin»: перший повноцінний вебфреймворк.
- FastAPI зсередини — уроки 37–39 (Pydantic, FastAPI + OpenAPI + Postman, CRUD з базою даних). Автентифікація — урок 41, тестування API — урок 42, WebSocket-чат — урок 46, Telegram Bot API з webhook — урок 48.
Документація і джерела¶
- REST: Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures, розділ 5 (2000); RFC 9110 — HTTP Semantics (методи, коди,
Location); RFC 5789 — PATCH - Настанови з дизайну API: Microsoft REST API Guidelines, Google API Design Guide
- Інші типи: JSON-RPC 2.0, GraphQL, gRPC і Protocol Buffers, WebSocket — RFC 6455, Server-Sent Events — стандарт HTML
- Бібліотеки: FastAPI, Strawberry GraphQL, grpcio, websockets, pymetdecoder, Streamlit, OpenAPI
- Дані: ogimet.com — телеграми SYNOP; код КН-01 / WMO FM 12 SYNOP
- Проєкт викладача:
NikoriakViktot/ogimet(перша версія API) і його розвиток —ogimet-mainта клієнтmeteo_parser/telegram_filter.py