Проєктування REST API та маршрутизація засобами Chi
Проєктування REST API та маршрутизація засобами Chi
🎯 Навчальні цілі
- Зрозуміти історичну еволюцію від RPC-систем першого покоління до сучасних HTTP API та віддалених викликів процедур нового покоління (gRPC, GraphQL).
- Усвідомити, чому протокол HTTP став універсальною мовою інтернету і де межа між HTTP як транспортом і HTTP як протоколом прикладного рівня.
- Розібратися з концепцією REST (Representational State Transfer) як архітектурного стилю: що описав Рой Філдінг у своїй дисертації 2000 року, як це інтерпретується у 2008 році та у реальній практиці.
- Детально вивчити складові HTTP-запитів та відповідей: URL, заголовки, HTTP-методи, статус-коди, їхню семантику та застосування у дизайні API.
- Опанувати принципи проєктування номенклатури URL: ієрархії ресурсів, параметри шляху (path) vs параметри запиту (query), версіонування API, консистентність найменувань.
- Навчитися правильно використовувати HTTP-методи (GET, POST, PUT, DELETE, PATCH) відповідно до їхньої семантики: безпека, ідемпотентність, симетрія операцій.
- Зрозуміти практичну реалізацію CRUD-операцій у HTTP API: створення ресурсів через POST/PUT, читання через GET, оновлення через PUT/PATCH, видалення через DELETE або архівування.
- Вивчити маршрутизатор go-chi/chi: структуру Radix Tree, параметри шляху (
chi.URLParam), групування маршрутів, суб-роутери та middleware. - Навчитися серіалізації та десеріалізації JSON засобами пакета
encoding/json, формувати стандартизовані відповіді згідно RFC 7807 Problem Details. - Побачити інтеграцію REST API з гетерогенними клієнтами: десктопними застосунками (JavaFX, C# Avalonia), мобільними (React Native), CLI-утилітами.
- Опанувати архітектурний патерн Clean Architecture у контексті HTTP API: розділення на рівні Domain, Repository, UseCase та HTTP Delivery.
🛠️ Технологічний стек
- Мова: Go 1.22+
- Маршрутизатор:
github.com/go-chi/chi/v5 - Стандартні пакети:
net/http,encoding/json,context - Інструменти тестування: Postman, cURL, HTTPie,
net/http/httptest - Клієнтські платформи: JavaFX / C# Avalonia (Desktop), React Native (Mobile), Go CLI
Історична еволюція: від RPC до HTTP API
Проблема віддалених обчислень та народження RPC
Виконання запитів користувача на віддаленому сервері є базовою задачею програмування ще з часів мейнфреймів (mainframes). Ця потреба отримала новий імпульс розвитку з появою мережі ARPANET. Хоча перші високорівневі протоколи сітьової взаємодії працювали у термінах відправки повідомлень по мережі (наприклад, протокол DEL, запропонований у одному з перших RFC — RFC-5 від 1969 року), дослідники досить швидко прийшли до думки, що було б зручніше, якби звернення до віддаленого вузла мережі з точки зору сигнатури виклику нічим не відрізнялося від звернення до локальної пам'яті та локальних ресурсів.
Цю концепцію суворо сформулював під назвою Remote Procedure Call (RPC) у 1981 році співробітник знаменитої лабораторії Xerox PARC Брюс Нельсон (Bruce Nelson), і він же був співавтором першої практичної імплементації запропонованої парадигми — Sun RPC, яка існує й сьогодні під назвою ONC RPC (Open Network Computing RPC).
Перші широко розповсюджені RPC-протоколи — такі як Sun RPC, Java RMI (Remote Method Invocation), CORBA (Common Object Request Broker Architecture) — чітко слідували парадигмі «прозорого» віддаленого виклику. Технологія дозволила робити саме те, про що писав Нельсон: не розрізняти локальне та віддалене виконання коду.
Криза RPC-підходів першого покоління
Зручність використування технології, однак, виявилася її ж Ахілловою п'ятою. RPC першого покоління зіткнулися з низкою фундаментальних проблем:
- Висока складність протоколу: Вимога працювати з віддаленими викликами як із локальними призводить до високої складності протоколу через необхідність підтримувати різноманітні можливості високорівневих мов програмування (наприклад, передачу складних об'єктів із станом, ієрархіями наслідування, покажчиками).
- Платформенна залежність: RPC першого покоління диктують вибір мови та платформи для клієнта і сервера:
- Sun RPC не працював під Windows;
- Java RMI вимагав віртуальну машину Java;
- Деякі протоколи (наприклад, CORBA) декларували можливість розробки адаптерів для будь-якої мови, проте фактична імплементація виявилася вкрай складною.
- Складність проксування та масштабування: Проксування запитів та шардування (sharding) даних ускладнено необхідністю читати та розбирати тіло запиту, що може бути ресурсоємно. Можливість адресувати об'єкти у пам'яті віддаленого сервера так само, як локальні, накладає величезні обмеження на масштабування такої системи.
HTTP як універсальна мова інтернету
Одночасно з ідеологічною кризою RPC-підходів відбувався інший процес — стандартизація мережевих протоколів. Ще на початку 1990-х спостерігалося величезне розмаїття форматів взаємодії, але поступово мережевий стек у двох точках практично повністю уніфікувався:
- Internet Protocol Suite (TCP/IP): Базовий протокол IP та надбудова у вигляді TCP або UDP над ним. Сьогодні альтернативи TCP/IP використовуються у вкрай обмеженому спектрі задач.
- Система доменних імен (DNS): Зручна абстракція над IP-адресами, що дозволяє призначити вузлам мережі людино-читабельні синоніми (human-readable names).
Для передачі текстових (гіпертекстових) даних таким протоколом став HTTP 0.9, розроблений Тімом Бернерсом-Лі (Tim Berners-Lee) та опублікований у 1991 році. Протокол був надзвичайно простим і описував спосіб отримати документ, відкривши TCP/IP з'єднання з сервером та передавши рядок виду:
GET адрес_документа
Пізніше протокол був доповнений стандартом URL (Uniform Resource Locator), що дозволяє деталізувати адресу документа, і далі протокол почав розвиватися стрімко: з'явилися нові методи (verbs) окрім GET, статуси відповідей, заголовки, типи даних тощо.
HTTP з'явився спочатку для передачі розміченого гіпертексту, що для програмних інтерфейсів підходить слабко. Однак HTML з часом еволюціонував у більш строгий та машино-читабельний XML, який швидко став одним із загальноприйнятих форматів опису викликів API. На початку 2000-х XML був швидко витіснений ще простішим та інтероперабельним JSON (JavaScript Object Notation).
Відродження RPC: технології другого покоління
У середині 1990-х років відбувається поступова відмова від RPC-фреймворків першого покоління на користь нового підходу, який пізніше Рой Філдінг у своїй дисертації 2000 року узагальнить під назвою Representational State Transfer (REST). У новій парадигмі відношення між даними та операціями над ними перевертаються з ніг на голову:
- Клієнт не викликає процедури на сервері з передачею параметрів — клієнт вказує серверу абстрактний адрес (локатор) фрагмента даних (ресурсу / resource), до якого він хоче застосувати операцію.
- Сам список операцій обмежений та стандартизований, семантика їх чітко визначена у стандарті.
- Клієнт та сервер незалежні та принципово не мають жодного розділеного стану (shared state); усі необхідні для виконання операції параметри мають бути передані явно.
- Між клієнтом та сервером може знаходитися множина проміжних вузлів мережі (проксі та шлюзи / proxies and gateways), що не впливає на протокол.
- Сервер розмічає параметри кешування переданих даних (представлення ресурсів / resource representations), клієнт (та проміжні проксі) мають право кешувати дані відповідно до розмітки.
Але неспокій дизайну API ніколи не вщухав. Починаючи з кінця 2010-х років ми спостерігаємо розквіт RPC-технологій нового покоління — або, вірніше, комбінованих технологій, які одночасно:
- Зручні у застосуванні в імперативних мовах програмування (постачаються з обгорткою, що дозволяє ефективно використовувати кодогенерацію).
- Інтероперабельні (працюють поверх строго стандартизованих протоколів, незалежних від конкретної мови програмування).
- Масштабовані (абстрагують поняття ресурсу та не надають прямого доступу до пам'яті сервера).
Приклади таких технологій: gRPC, GraphQL, Apache Thrift, Apache Avro. Цікаво, що майже всі вони (за винятком MQTT) працюють поверх протоколу HTTP. Таким чином, більшість сучасних RPC-протоколів одночасно є HTTP API.
Тому що існують фундаментальні відмінності між локальним та віддаленим викликом:
- Мережеві затримки: Локальний виклик функції займає наносекунди, віддалений — мілісекунди або секунди.
- Можливість відмови: Мережа може впасти у будь-який момент, TCP-з'єднання може розірватися, сервер може бути недоступним.
- Часткові збої: Запит може бути доставлено, але відповідь загублено. Важко визначити, чи була операція виконана.
- Серіалізація: Не всі об'єкти можна серіалізувати (наприклад, покажчики на функції, відкриті файлові дескриптори).
Приховування цих відмінностей призводить до непередбачуваних помилок. Натомість сучасні підходи явно показують, що виклик віддалений: передають context.Context для таймаутів, повертають error для обробки мережевих збоїв, використовують асинхронні паттерни.
Архітектурний стиль REST: теорія та міфологія
REST за Філдінгом-2000: архітектурні обмеження
У 2000 році Рой Філдінг (Roy Fielding), один з авторів специфікацій HTTP та URI, захистив докторську дисертацію під назвою «Architectural Styles and the Design of Network-based Software Architectures» («Архітектурні стилі та дизайн архітектури мережевого програмного забезпечення»). П'ята глава цієї дисертації називалася Representational State Transfer (REST).
Важливо зрозуміти: ця глава є абстрактним оглядом розподіленої мережевої архітектури, взагалі не прив'язаної ні до HTTP, ні до URL. Вона не присвячена правилам дизайну API — у цій главі Філдінг методично перелічує обмеження, з якими стикається розробник розподіленого мережевого програмного забезпечення:
Обмеження 1: Клієнт-серверна архітектура
Client-Server: Клієнт та сервер не знають внутрішнього устрою один одного. Інтерфейс взаємодії є публічним контрактом, але імплементація прихована.
Обмеження 2: Відсутність стану на сервері
Stateless: Сесія зберігається на клієнті. Сервер не зберігає контекст клієнта між запитами. Кожен запит містить усю необхідну інформацію для його виконання.
Обмеження 3: Кешування
Cacheable: Дані повинні розмічатися як кешовані (cacheable) або некешовані (non-cacheable). Це дозволяє клієнтам та проміжним проксі зберігати копії даних для підвищення продуктивності.
Обмеження 4: Уніфікований інтерфейс
Uniform Interface: Інтерфейси взаємодії між компонентами повинні бути стандартизовані. Незалежно від того, який сервер стоїть за API, клієнт взаємодіє з ним однаковим чином.
Обмеження 5: Багатошаровість
Layered System: Мережеві системи є багатошаровими, тобто сервер може бути лише проксі до інших серверів. Клієнт не повинен знати, чи спілкується він безпосередньо з кінцевим сервером чи через проміжні вузли.
Обмеження 6: Код за запитом (опціонально)
Code-On-Demand (optional): Функціональність клієнта може бути розширена через постачання коду з сервера (наприклад, JavaScript у браузері).
Саме цим REST і обмежується у дисертації Філдінга. Далі він конкретизує аспекти імплементації систем у зазначених обмеженнях, але всі вони так само є абстрактними. Наприклад: «Ключова інформаційна абстракція у REST — це ресурс; будь-яка інформація, якій можна дати найменування, може бути ресурсом».
REST за Філдінгом-2008: гіпермедіа та HATEOAS
Ситуація ускладнилася у 2008 році, коли Філдінг випустив роз'яснення, що ж він насправді мав на увазі. У цій статті стверджується, що:
- Розробка REST API повинна фокусуватися на описі медіатипів (media types), що представляють ресурси; при цьому клієнт взагалі нічого про ці медіатипи знати не повинен заздалегідь.
- У REST API не має бути фіксованих імен ресурсів та операцій над ними, клієнт повинен витягувати цю інформацію з відповідей сервера.
REST за Філдінгом-2008 підпорядковується принципу HATEOAS (Hypermedia As The Engine Of Application State / «Гіпермедіа як двигун стану застосунку»). Це означає, що клієнт, отримавши якимось чином посилання на точку входу у REST API, далі має бути у стані повністю вибудувати взаємодію з API, не володіючи взагалі жодним апріорним знанням про нього.
REST у реальному житті: JSON-over-HTTP API
Термін «REST API» або «RESTful API» у практичному використанні має розмите значення. За відсутністю чітких визначень тема REST API перетворилася на одне з найбільших джерел холіварів серед програмістів — притому холіварів безглуздих, оскільки популярне уявлення про REST не має майже жодного відношення до того REST, який описаний у дисертації Філдінга.
У цій лекції ми будемо використовувати термін HTTP API або JSON-over-HTTP API для позначення API з такими характеристиками:
- Протоколом взаємодії є HTTP версій 1.1 та вище.
- Форматом даних є JSON (за винятком ендпоінтів, призначених для передачі файлів у інших форматах).
- В якості ідентифікаторів ресурсів використовується URL відповідно до стандарту.
- Семантика викликів HTTP-ендпоінтів відповідає специфікації.
- Жоден із веб-стандартів ніде не порушується навмисно.
Переваги та недоліки HTTP API порівняно з альтернативами
Формат даних: JSON vs бінарні протоколи
Вибір технології для розробки клієнт-серверних API зводиться до вибору між ресурсо-орієнтованим підходом (HTTP API) та одним із сучасних RPC-протоколів (gRPC, GraphQL, Apache Thrift).
Переваги JSON:
- Людино-читабельність: JSON є текстовим форматом, який не вимагає додаткової розшифровки. На відміну від Protocol Buffers, де неможливо прочитати повідомлення без
.proto-файлу, JSON-документ майже завжди дозволяє зрозуміти, які дані в ньому описані. - Максимальна формальність: JSON не має конструкцій, які можуть бути по-різному витлумачені у різних архітектурах, та зручно проектується на нативні структури даних (індексні та асоціативні масиви) майже будь-якої мови програмування.
- Простота налагодження: Запити та відповіді можна легко читати у логах, інспектувати у браузерних DevTools, копіювати та модифікувати у Postman чи cURL.
Недоліки JSON:
- Надлишковість формату: У JSON необхідно щоразу передавати імена всіх полів, навіть якщо передається масив з великої кількості однакових об'єктів. Також є велика кількість технічних символів — лапок, дужок, ком.
- Розмір даних: Без стиснення JSON-документи можуть бути значно більшими за бінарні формати (Protocol Buffers, FlatBuffers).
Машино-читабельність метаданих
Принципова відмінність між використанням HTTP як протоколу прикладного рівня (HTTP API) та HTTP як транспорту (gRPC, GraphQL): у першому випадку метадані запиту можна прочитати без читання тіла запиту або відповіді.
Протокол HTTP дозволяє прочитати такі деталі, як метод та URL запиту, статус операції, заголовки запиту та відповіді, не читаючи тіло запиту або відповіді цілком. Для більшості протоколів вищого рівня (JSON-RPC, GraphQL) це не так: навіть якщо агент володіє підтримкою протоколу, йому необхідно прочитати та розібрати тіло відповіді.
Переваги машино-читабельних метаданих:
Сучасний стек взаємодії між клієнтом та сервером є багатошаровим. HTTP-запит проходить через:
- Клієнтський фреймворк (Retrofit для Android, Axios для JavaScript).
- API операційної системи (сокети, TCP/IP стек).
- Проміжні HTTP-проксі та балансувальники навантаження (nginx, Envoy).
- API-шлюзи (API Gateway) та сервіс-меші (Service Mesh).
- Веб-сервер на стороні бекенду (nginx, Apache).
- Серверний фреймворк (Go net/http, Chi, Gin).
Головна перевага дотримання букви стандарту HTTP — можливість покластися на те, що проміжні агенти вміють читати метадані запиту та виконувати дії з їхнім використанням — налаштовувати політику перезапитів та таймаути, логувати, кешувати, шардувати, проксувати — без необхідності писати додатковий код.
Недолік: Проміжні агенти можуть трактувати метадані неправильно. Якщо розробник API або проміжного ПЗ не знає специфікацію HTTP досконало, можуть виникнути помилки. Наприклад, деякі проксі за замовчуванням кешують 404 Not Found, що може призвести до проблем, якщо ресурс з'являється пізніше.
Вибір технології: коли HTTP API, коли gRPC?
HTTP API (JSON-over-HTTP) підходить для:
- Публічних API: Зрозумілий максимально широкому колу програмістів, дозволяє розробляти клієнтські застосунки практично на будь-якій платформі.
- MVP та прототипів: Швидка розробка, величезна екосистема інструментів (Postman, Swagger UI, OpenAPI).
- Вебклієнтів: Нативна підтримка у браузерах через
fetch()таXMLHttpRequest.
gRPC підходить для:
- Внутрішньої міжсервісної взаємодії: Висока продуктивність завдяки бінарній серіалізації Protocol Buffers та HTTP/2.
- Систем реального часу: Підтримка двонапрямлених потоків (bidirectional streaming).
- Строго типізованих контрактів: Contract-first підхід з кодогенерацією клієнтів для різних мов.
Складові HTTP-запитів та відповідей: детальний розбір
Анатомія HTTP-запиту
HTTP-запит складається з наступних компонентів:
- Стартовий рядок (request line): метод, URL та версія протоколу.
- Заголовки (headers): метаінформація у форматі
ключ: значення. - Порожній рядок: Розділювач між заголовками та тілом.
- Тіло запиту (body): Опціональне, містить дані (JSON, XML, форма, бінарні дані).
Приклад повного HTTP-запиту:
POST /v1/orders HTTP/1.1
Host: api.coffeeshop.dev
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Length: 147
{
"coffee_machine_id": 123,
"recipe": "lungo",
"volume": "800ml",
"price": {
"amount": "10.23",
"currency": "UAH"
}
}
Структура URL: схема, хост, шлях, параметри запиту
URL (Uniform Resource Locator) є одиницею адресації у HTTP API. Формат URL регулюється окремим стандартом, що розробляється спільнотою WHATWG (Web Hypertext Application Technology Working Group).
URL складається з наступних компонентів:
https://api.coffeeshop.dev:443/v1/orders/123?include=items&format=json#section
└───┘ └──────────────────┘ └─┘ └──────────┘ └────────────────────┘ └──────┘
схема хост порт шлях параметри запиту фрагмент
- Схема (scheme): Протокол звернення, у нашому випадку завжди
https:(абоhttp:для локальної розробки). - Хост (host): Доменне ім'я або IP-адреса сервера, може включати піддомени.
- Порт (port): Опціонально, за замовчуванням
443для HTTPS та80для HTTP. - Шлях (path): Частина URL між хостом (з портом) та символами
?,#або кінцем рядка. Прийнято розбивати по символу/та працювати з кожною частиною як окремим токеном. - Параметри запиту (query): Частина URL після знака
?до знака#або кінця рядка. Прийнято розкладати на париключ=значення, розділені символом&. - Фрагмент (fragment / anchor): Частина URL після знака
#. Традиційно розглядається як адресація всередині запитаного документа, тому багатьма агентами опускається при виконанні запиту.
Традиційна семантика URL у HTTP API
У HTTP API історично склалися наступні конвенції:
- Частини шляху (path segments) використовуються для організації вкладених сутностей зі строгою ієрархією:
/partners/{partner_id}/coffee-machines/{machine_id}
Тут кофемашина належить строго одному партнеру, тому це відображено у вкладеній структурі URL. - Параметри запиту (query parameters) використовуються для:
- Нестрогої ієрархії (відношення «багато до багатьох»):
/recipes?partner_id=<partner_id>
Рецепти можуть належати багатьом партнерам, томуpartner_id— це фільтр, а не частина ієрархії. - Параметрів операції (фільтрація, сортування, пагінація):
/orders?user_id=<user_id>&status=pending&limit=50&offset=100
- Нестрогої ієрархії (відношення «багато до багатьох»):
/orders/123 та /orders/123/ з точки зору стандарту є різними URL. Однак практично немає аргументів на користь того, щоб не вважати такі шляхи еквівалентними. Більшість маршрутизаторів автоматично нормалізують такі URL або налаштовуються на ігнорування фінального слеша.HTTP-заголовки: метаінформація запиту
Заголовки — це метаінформація, прив'язана до запиту або відповіді. Вони можуть описувати властивості переданих даних, додаткові відомості про клієнта або сервера, або містити поля, не пов'язані безпосередньо зі змістом запиту/відповіді.
Ключові стандартні заголовки:
Host: Обов'язковий заголовок, що вказує доменне ім'я сервера. Необхідний для віртуального хостингу (virtual hosting), коли одна IP-адреса обслуговує багато доменів.Content-Type: Вказує MIME-тип тіла запиту/відповіді (наприклад,application/json,multipart/form-data).Content-Length: Розмір тіла у байтах. Деякі проксі взагалі не пропускають запити без цього заголовка.Authorization: Містить облікові дані для автентифікації (наприклад, JWT-токен:Bearer <token>).Accept: Вказує, які типи даних клієнт готовий прийняти у відповіді.User-Agent: Ідентифікатор клієнтського застосунку (браузер, мобільний додаток).Date: Таймстемп створення запиту/відповіді для синхронізації годинників.
Користувацькі заголовки:
Для передачі додаткових метаданих, специфічних для вашого API, рекомендується використовувати префікс у форматі X-ApiName-FieldName, щоб уникнути колізій із стандартними або майбутніми заголовками:
X-CoffeeAPI-Request-ID: 7f3a8b2c-1e4d-4a9b-8c3f-2d1e4a7b9c3f
X-CoffeeAPI-Client-Version: 2.5.1
X- для нестандартних заголовків, але RFC 6648 від 2012 року оголосив цю практику застарілою. Проте відмови від префіксу X- фактично не відбулося, і багато популярних нестандартних заголовків (наприклад, X-Forwarded-For, X-Request-ID) все ще його містять.HTTP-методи (глаголи): семантика та побічні ефекти
HTTP-метод визначає семантику операції та її побічні дії. Стандарт RFC 9110 визначає вісім методів, із яких для розробки API найважливіші п'ять: GET, POST, PUT, DELETE, PATCH.
Таблиця семантики HTTP-методів
| Метод | Семантика | Безпечний | Ідемпотентний | Може мати тіло |
|---|---|---|---|---|
GET | Повертає представлення ресурсу | ✅ Так | ✅ Так | ❌ Ні (не рекомендується) |
POST | Обробляє запит відповідно до внутрішньої семантики ресурсу | ❌ Ні | ❌ Ні | ✅ Так |
PUT | Заміняє (повністю перезаписує) ресурс даними з тіла запиту | ❌ Ні | ✅ Так | ✅ Так |
PATCH | Модифікує (частково перезаписує) ресурс даними з тіла запиту | ❌ Ні | ❌ Ні | ✅ Так |
DELETE | Видаляє ресурс | ❌ Ні | ✅ Так | ❌ Ні (не рекомендується) |
Пояснення властивостей:
- Безпечний метод (safe method): Не модифікує ресурси на сервері. Виклик
GET /orders/123завжди має тільки читати дані, ніколи їх не змінювати. - Ідемпотентний метод (idempotent method): Повторний виклик з тими самими параметрами залишає систему у тому самому стані, що й один виклик. Наприклад:
PUT /orders/123перезаписує замовлення123. Повторний виклик з тим самим тілом не змінює результат.DELETE /orders/123видаляє замовлення123. Повторний виклик не змінює стан (замовлення вже видалено).POST /ordersстворює нове замовлення. Повторний виклик створить ще одне замовлення (дублікат), тому це не ідемпотентно.
PUT, DELETE) URL запиту є його ключем ідемпотентності. Якщо ви викличете PUT /orders/123 двічі з однаковим тілом, ресурс /orders/123 буде у тому самому стані після обох викликів.Симетричність GET / PUT / DELETE
Ідемпотентність та симетричність методів GET, PUT, DELETE означає, що:
- Якщо ви викликали
PUT /orders/123з тілом{"status": "completed"}, то наступнийGET /orders/123має повернути представлення ресурсу, що відповідає переданому уPUT. - Після виклику
DELETE /orders/123, наступнийGET /orders/123має повернути статус404 Not Foundабо410 Gone(ресурс видалено).
Це створює консистентну модель, де клієнт може покластися на те, що результат GET відображає останню операцію PUT або DELETE.
Статус-коди HTTP: машино-читабельні результати операцій
Статус-код — це машино-читабельний опис результату HTTP-запиту у вигляді трицифрового числа. Усі статус-коди діляться на п'ять великих груп:
- 1xx — Інформаційні (рідко використовуються; приклад:
100 Continue). - 2xx — Успіх операції (
200 OK,201 Created,204 No Content). - 3xx — Перенаправлення (
301 Moved Permanently,302 Found,304 Not Modified). - 4xx — Клієнтські помилки (
400 Bad Request,401 Unauthorized,404 Not Found,422 Unprocessable Entity). - 5xx — Серверні помилки (
500 Internal Server Error,502 Bad Gateway,503 Service Unavailable).
xyz, який йому невідомий, згідно зі специфікацією він має виконати ту дію, яку б виконав при отриманні коду x00. Наприклад, отримавши невідомий код 499, клієнт має обробити його як 400 Bad Request.Найпопулярніші статус-коди для REST API
Успішні операції (2xx):
200 OK: Запит успішно оброблено, відповідь містить представлення ресурсу (використовується дляGET,PUT,PATCH).201 Created: Ресурс успішно створено (використовується дляPOST). Рекомендується повертати заголовокLocationз URL нового ресурсу.204 No Content: Запит успішно оброблено, але відповідь не містить тіла (використовується дляDELETEабоPUT, якщо немає чого повертати).
Клієнтські помилки (4xx):
400 Bad Request: Загальна помилка валідації запиту (некоректний JSON, відсутні обов'язкові поля).401 Unauthorized: Клієнт не автентифікований (відсутній або невалідний токен).403 Forbidden: Клієнт автентифікований, але не має прав на виконання операції.404 Not Found: Ресурс не знайдено.409 Conflict: Конфлікт стану (наприклад, спроба створити ресурс з ідентифікатором, який вже існує).422 Unprocessable Entity: Запит синтаксично коректний, але семантично некоректний (наприклад, бізнес-правила не виконані).
Серверні помилки (5xx):
500 Internal Server Error: Загальна серверна помилка.502 Bad Gateway: Проксі-сервер отримав некоректну відповідь від upstream-сервера.503 Service Unavailable: Сервіс тимчасово недоступний (наприклад, через перевантаження або технічне обслуговування).
4xx коди не кешуються, за винятком 404, 405, 410, 414. Це може призвести до несподіваної поведінки. Наприклад, якщо ваш API повертає 404 для ресурсу, який може з'явитися пізніше, деякі клієнти або проксі можуть закешувати цю відповідь, і клієнт не дізнається про появу ресурсу, поки не спливе TTL кешу.Принципи організації HTTP API: від монолітного ендпоінту до мікросервісів
Антипаттерн: монолітний Stateful-ендпоінт
Розглянемо типову помилку у проєктуванні HTTP API. Уявімо процедуру старту застосунку: клієнт має отримати профіль поточного користувача та його активні замовлення. Інтуїтивно хочеться створити такий ендпоінт:
GET /v1/state HTTP/1.1
Authorization: Bearer <token>
→ HTTP/1.1 200 OK
{
"profile": {
"user_id": "user_123",
"name": "Андрій Петренко",
"email": "andrii@example.com"
},
"orders": [
{"id": "order_456", "status": "pending", "recipe": "espresso"},
{"id": "order_789", "status": "completed", "recipe": "cappuccino"}
]
}
Цей підхід здається зручним, але порушує кілька архітектурних принципів REST:
- Stateful-дизайн: Сервер має зберігати токени у пам'яті, щоб витягти з них ідентифікатор клієнта, до якого прив'язані дані. Це створює спільний стан між клієнтом і сервером.
- Неможливість кешування: Профіль користувача змінюється рідко, а замовлення — часто. Але цей ендпоінт повертає обидва типи даних разом, тому кешувати відповідь немає сенсу.
- **Однош
арова система:** Неможливо декомпозувати цей ендпоінт на мікросервіси, бо вся логіка зосереджена в одному місці.
Рефакторинг до Stateless-архітектури
Давайте перепроєктуємо це API відповідно до принципів REST. Припустимо, ми вирішили декомпозувати монолітний бекенд на чотири мікросервіси:
- Сервіс A: Перевірка авторизаційних токенів (або використання JWT для stateless-автентифікації).
- Сервіс B: Зберігання профілів користувачів.
- Сервіс C: Зберігання замовлень користувачів.
- Сервіс-шлюз D: Маршрутизує запити між мікросервісами.
Замість одного монолітного запиту GET /v1/state створимо два незалежні ресурси:
GET /v1/profiles/{user_id} HTTP/1.1
→ HTTP/1.1 200 OK
Cache-Control: max-age=3600
{
"user_id": "user_123",
"name": "Андрій Петренко",
"email": "andrii@example.com"
}
GET /v1/orders?user_id=user_123 HTTP/1.1
→ HTTP/1.1 200 OK
ETag: "v123abc"
Cache-Control: no-cache
[
{"id": "order_456", "status": "pending", "recipe": "espresso"},
{"id": "order_789", "status": "completed", "recipe": "cappuccino"}
]
Переваги цього підходу:
- Stateless: Кожен запит містить усю необхідну інформацію (
user_idявно передано у URL або токені). Сервіси не зберігають стан клієнта між запитами. - Кешування: Профілі можна кешувати на годину (
Cache-Control: max-age=3600), а замовлення перевіряти через умовні запити зETag. - Масштабованість: Кожен мікросервіс можна масштабувати незалежно. Наприклад, якщо замовлень багато, можна запустити більше інстансів Сервісу C.
- Уніфікований інтерфейс: Шлюз D може бути замінено на будь-який інший проксі або навіть на клієнтський код — всі сервіси спілкуються стандартним HTTP.
Проєктування номенклатури URL: ієрархія ресурсів та CRUD
Правила організації URL у HTTP API
Стандарти HTTP та URL не визначають семантики частин URL (path, query). Проте історично склалися конвенції, яких варто дотримуватися для читабельності та інтуїтивності API:
- Використовуйте іменники, а не дієслова:
- ✅ Правильно:
GET /orders/123,POST /orders,DELETE /orders/123 - ❌ Неправильно:
GET /getOrder?id=123,POST /createOrder,POST /deleteOrder
- ✅ Правильно:
- Використовуйте множину для колекцій:
- ✅ Правильно:
/orders,/users,/products - ❌ Неправильно:
/order,/user,/product
- ✅ Правильно:
- Дотримуйтесь єдиного стилю іменування:
kebab-caseдля шляху:/coffee-machines,/user-profilessnake_caseдля параметрів запиту та JSON-полів:?user_id=123,{"order_id": 456}
- Версіонуйте API через шлях:
- ✅ Правильно:
/v1/orders,/v2/orders - ❌ Альтернативи (менш поширені):
?version=1, заголовокX-API-Version: 1
- ✅ Правильно:
/v1/...) є найпопулярнішим підходом, оскільки:- Версія видна одразу у URL, що полегшує налагодження.
- Не потрібно налаштовувати спеціальні заголовки або параметри запиту.
- URL чітко показує, що це різні API, навіть якщо вони обслуговуються одним сервером.
CRUD-операції: від теорії до практики
Акронім CRUD (Create, Read, Update, Delete) описує чотири базові операції над ресурсами. Концепція CRUD ідеально проектується на HTTP-методи:
| CRUD-операція | HTTP-метод | Приклад | Опис |
|---|---|---|---|
| Create | POST | POST /orders | Створити нове замовлення |
| Read | GET | GET /orders/123 | Прочитати замовлення з ID 123 |
| Update | PUT | PUT /orders/123 | Повністю замінити замовлення 123 |
PATCH | PATCH /orders/123 | Частково оновити замовлення 123 | |
| Delete | DELETE | DELETE /orders/123 | Видалити замовлення 123 |
Створення ресурсів (Create)
Базовий підхід:
POST /v1/orders HTTP/1.1
Content-Type: application/json
{
"user_id": "user_123",
"recipe": "espresso",
"volume": "50ml"
}
→ HTTP/1.1 201 Created
Location: /v1/orders/order_456
{
"id": "order_456",
"user_id": "user_123",
"recipe": "espresso",
"status": "pending"
}
Проблема: POST за замовчуванням не є ідемпотентним. Якщо клієнт отримає таймаут і повторить запит, може бути створено два замовлення.
Рішення 1: Токен ідемпотентності
POST /v1/orders HTTP/1.1
Idempotency-Key: 7f3a8b2c-1e4d-4a9b-8c3f-2d1e4a7b9c3f
Content-Type: application/json
{
"user_id": "user_123",
"recipe": "espresso"
}
Сервер зберігає Idempotency-Key та при повторному запиті з тим самим ключем повертає результат попередньої операції.
Рішення 2: Схема чернетка-підтвердження
POST /v1/drafts HTTP/1.1
{"user_id": "user_123", "recipe": "espresso"}
→ 201 Created
Location: /v1/drafts/draft_789
PUT /v1/drafts/draft_789/commit HTTP/1.1
{"confirm": true}
→ 200 OK
Location: /v1/orders/order_456
Цей підхід розділяє створення чернетки (легка операція) та підтвердження (важка операція, але ідемпотентна через PUT).
Читання ресурсів (Read)
GET /v1/orders/123 HTTP/1.1
→ HTTP/1.1 200 OK
{
"id": "order_123",
"user_id": "user_456",
"recipe": "cappuccino",
"status": "completed"
}
Для списків з фільтрацією та пагінацією:
GET /v1/orders?user_id=user_456&status=pending&limit=20&offset=0
Для складних пошукових запитів може знадобитися POST:
POST /v1/orders/search HTTP/1.1
{
"filters": {
"recipes": ["espresso", "cappuccino"],
"date_range": {"start": "2026-01-01", "end": "2026-12-31"}
},
"sort": {"field": "created_at", "order": "desc"},
"pagination": {"limit": 50, "offset": 0}
}
GET семантично кращий (операція безпечна), POST дозволяє передати складну структуру у тілі запиту. Це компроміс між семантикою та практичністю.Оновлення ресурсів (Update)
Повна заміна через PUT:
PUT /v1/orders/123 HTTP/1.1
Content-Type: application/json
{
"user_id": "user_456",
"recipe": "latte",
"volume": "300ml",
"status": "pending"
}
→ HTTP/1.1 200 OK
{
"id": "order_123",
"user_id": "user_456",
"recipe": "latte",
"volume": "300ml",
"status": "pending"
}
PUT повністю замінює ресурс. Усі поля, не передані у запиті, мають бути скинуті до значень за замовчуванням або видалені.
Часткове оновлення через PATCH:
PATCH /v1/orders/123 HTTP/1.1
Content-Type: application/json
{
"status": "completed"
}
→ HTTP/1.1 200 OK
{
"id": "order_123",
"user_id": "user_456",
"recipe": "espresso",
"volume": "50ml",
"status": "completed"
}
PATCH частково модифікує ресурс. Оновлюються лише передані поля, інші залишаються без змін.
PATCH може виконувати операції, залежні від поточного стану (наприклад, інкремент лічильника). Тому повторний виклик може дати інший результат. На практиці більшість API трактують PATCH як ідемпотентний, якщо операція є простою заміною полів.Видалення ресурсів (Delete)
М'яке видалення (архівування):
PUT /v1/orders/123/archive HTTP/1.1
→ HTTP/1.1 204 No Content
У сучасних системах дані рідко видаляються фізично, натомість вони архівуються або помічаються видаленими через зміну статусу.
Жорстке видалення:
DELETE /v1/orders/123 HTTP/1.1
→ HTTP/1.1 204 No Content
Після цього GET /v1/orders/123 має повертати 404 Not Found або 410 Gone.
DELETE /v1/orders/123 не має викликати помилку, навіть якщо ресурс вже видалено. Правильна поведінка — повернути 204 No Content або 404 Not Found, але не 500 Internal Server Error.Маршрутизатор go-chi/chi: потужний інструмент для REST API
Чому Chi, а не стандартний ServeMux?
Стандартний роутер http.ServeMux має обмежену функціональність:
- Немає параметрів шляху: Неможливо створити маршрут виду
/orders/{id}. - Немає middleware: Доводиться вручну обгортати обробники для логування, CORS, автентифікації.
- Немає групування маршрутів: Складно організувати версіонування API або загальні префікси.
Chi — це легковаговий, але потужний маршрутизатор, який вирішує всі ці проблеми, залишаючись повністю сумісним зі стандартним інтерфейсом http.Handler.
Встановлення та базове використання
go get -u github.com/go-chi/chi/v5
Базовий приклад:
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Middleware: логування запитів, відновлення після паніки
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Маршрути
r.Get("/", homeHandler)
r.Get("/health", healthCheckHandler)
r.Route("/v1", func(r chi.Router) {
r.Get("/orders", listOrdersHandler)
r.Post("/orders", createOrderHandler)
r.Get("/orders/{orderID}", getOrderHandler)
r.Put("/orders/{orderID}", updateOrderHandler)
r.Delete("/orders/{orderID}", deleteOrderHandler)
})
http.ListenAndServe(":8080", r)
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Welcome to Coffee API"))
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}
func listOrdersHandler(w http.ResponseWriter, r *http.Request) {
// Витягуємо query-параметри
userID := r.URL.Query().Get("user_id")
// ... логіка отримання списку замовлень
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": "order_1", "user_id": userID, "recipe": "espresso"},
})
}
func getOrderHandler(w http.ResponseWriter, r *http.Request) {
// Витягуємо параметр шляху
orderID := chi.URLParam(r, "orderID")
// ... логіка отримання замовлення за ID
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": orderID,
"recipe": "cappuccino",
"status": "completed",
})
}
func createOrderHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID string `json:"user_id"`
Recipe string `json:"recipe"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
defer r.Body.Close()
// ... логіка створення замовлення
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order_789",
"user_id": req.UserID,
"recipe": req.Recipe,
"status": "pending",
})
}
func updateOrderHandler(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "orderID")
// ... логіка оновлення замовлення
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"id": orderID, "status": "updated"})
}
func deleteOrderHandler(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "orderID")
// ... логіка видалення замовлення
w.WriteHeader(http.StatusNoContent)
}
Параметри шляху: chi.URLParam
Chi використовує дерево пошуку Radix Tree для ефективного зіставлення URL із маршрутами. Параметри шляху визначаються через фігурні дужки:
r.Get("/orders/{orderID}", getOrderHandler)
r.Get("/users/{userID}/orders/{orderID}", getUserOrderHandler)
Витягування параметрів:
func getOrderHandler(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "orderID")
fmt.Fprintf(w, "Order ID: %s", orderID)
}
orderID := chi.URLParam(r, "orderID")
id, err := strconv.Atoi(orderID)
if err != nil {
http.Error(w, "Invalid order ID", http.StatusBadRequest)
return
}
Групування маршрутів та суб-роутери
Групування дозволяє застосовувати спільні middleware або префікси до набору маршрутів:
r.Route("/v1", func(r chi.Router) {
// Middleware тільки для /v1/*
r.Use(corsMiddleware)
r.Use(authMiddleware)
r.Get("/health", healthHandler)
r.Route("/orders", func(r chi.Router) {
r.Get("/", listOrdersHandler) // GET /v1/orders
r.Post("/", createOrderHandler) // POST /v1/orders
r.Get("/{id}", getOrderHandler) // GET /v1/orders/{id}
r.Put("/{id}", updateOrderHandler) // PUT /v1/orders/{id}
r.Delete("/{id}", deleteOrderHandler) // DELETE /v1/orders/{id}
})
})
Створення користувацьких Middleware
Middleware у Chi — це функція, що обгортає http.Handler:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Викликаємо наступний обробник у ланцюжку
next.ServeHTTP(w, r)
// Логуємо після виконання запиту
duration := time.Since(start)
log.Printf("%s %s %v", r.Method, r.URL.Path, duration)
})
}
// Застосування middleware
r.Use(loggingMiddleware)
Middleware для CORS:
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Обробка preflight-запитів
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
Middleware для автентифікації:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Перевірка токена (наприклад, JWT)
userID, err := validateToken(token)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Додаємо userID до контексту
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Вбудовані middleware пакета chi/middleware
Chi постачається з багатим набором готових middleware:
import "github.com/go-chi/chi/v5/middleware"
func main() {
r := chi.NewRouter()
// 1. Logger - логування кожного запиту
r.Use(middleware.Logger)
// 2. Recoverer - відновлення після паніки
r.Use(middleware.Recoverer)
// 3. RequestID - додає унікальний ID до кожного запиту
r.Use(middleware.RequestID)
// 4. RealIP - витягує реальний IP клієнта (за проксі)
r.Use(middleware.RealIP)
// 5. Timeout - встановлює таймаут для обробки запиту
r.Use(middleware.Timeout(60 * time.Second))
// 6. Throttle - обмеження кількості одночасних запитів
r.Use(middleware.Throttle(100))
// 7. Compress - стиснення відповідей (gzip, deflate)
r.Use(middleware.Compress(5))
// 8. StripSlashes - нормалізація слешів у кінці URL
r.Use(middleware.StripSlashes)
// 9. Heartbeat - ендпоінт для health checks
r.Use(middleware.Heartbeat("/ping"))
// 10. AllowContentType - дозволяє лише певні Content-Type
r.Use(middleware.AllowContentType("application/json"))
// ... ваші маршрути
}
Пояснення ключових middleware:
1. RequestID - унікальний ідентифікатор запиту
r.Use(middleware.RequestID)
func someHandler(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetReqID(r.Context())
log.Printf("[%s] Processing request", requestID)
// Додаємо Request ID до відповіді для трейсингу
w.Header().Set("X-Request-ID", requestID)
}
2. RealIP - отримання справжньої IP-адреси клієнта
r.Use(middleware.RealIP)
func someHandler(w http.ResponseWriter, r *http.Request) {
// Витягує IP з заголовків X-Forwarded-For, X-Real-IP
clientIP := r.RemoteAddr
log.Printf("Request from IP: %s", clientIP)
}
3. Throttle - обмеження одночасних запитів
// Дозволяємо не більше 100 одночасних запитів
r.Use(middleware.Throttle(100))
// Якщо ліміт перевищено, Chi повертає 429 Too Many Requests
4. Timeout - таймаут обробки запиту
// Якщо обробник не встигне відповісти за 30 секунд,
// Chi поверне 504 Gateway Timeout
r.Use(middleware.Timeout(30 * time.Second))
func slowHandler(w http.ResponseWriter, r *http.Request) {
// Можна перевірити, чи не скасовано контекст
select {
case <-time.After(5 * time.Second):
w.Write([]byte("Done"))
case <-r.Context().Done():
// Таймаут або клієнт розірвав з'єднання
return
}
}
Монтування суб-роутерів через Mount
Mount дозволяє підключити окремий роутер як модуль:
func main() {
r := chi.NewRouter()
// Монтуємо API v1
r.Mount("/api/v1", apiV1Router())
// Монтуємо API v2
r.Mount("/api/v2", apiV2Router())
// Монтуємо адмін-панель
r.Mount("/admin", adminRouter())
http.ListenAndServe(":8080", r)
}
func apiV1Router() chi.Router {
r := chi.NewRouter()
r.Use(corsMiddleware)
r.Get("/orders", listOrdersHandler)
r.Post("/orders", createOrderHandler)
return r
}
func apiV2Router() chi.Router {
r := chi.NewRouter()
r.Use(corsMiddleware)
r.Use(authMiddleware) // V2 вимагає автентифікації
r.Get("/orders", listOrdersV2Handler)
r.Post("/orders", createOrderV2Handler)
return r
}
func adminRouter() chi.Router {
r := chi.NewRouter()
r.Use(adminAuthMiddleware) // Окрема автентифікація для адмінів
r.Get("/users", listUsersHandler)
r.Get("/stats", statsHandler)
return r
}
Обробка 404 та 405 помилок
Chi дозволяє налаштувати користувацькі обробники для неіснуючих маршрутів:
r := chi.NewRouter()
// Користувацький обробник для 404 Not Found
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Route not found",
"path": r.URL.Path,
"method": r.Method,
})
})
// Користувацький обробник для 405 Method Not Allowed
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": fmt.Sprintf("Method %s not allowed for %s", r.Method, r.URL.Path),
"allowed_methods": []string{"GET", "POST"}, // Можна динамічно визначати
})
})
Передача даних через Context
Chi використовує стандартний context.Context для передачі даних між middleware та обробниками:
// Middleware додає дані до контексту
func userMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
// Завантажуємо користувача з БД
user, err := getUserFromDB(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Додаємо користувача до контексту
ctx := context.WithValue(r.Context(), "user", user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Обробник витягує дані з контексту
func getUserOrdersHandler(w http.ResponseWriter, r *http.Request) {
// Витягуємо користувача з контексту
user := r.Context().Value("user").(*User)
orders := getOrdersForUser(user.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orders)
}
// Використання
r.Route("/users/{userID}", func(r chi.Router) {
r.Use(userMiddleware) // Застосовується до всіх маршрутів нижче
r.Get("/orders", getUserOrdersHandler)
r.Get("/profile", getUserProfileHandler)
})
type contextKey string
const userKey contextKey = "user"
// Встановлення
ctx := context.WithValue(r.Context(), userKey, user)
// Отримання
user, ok := r.Context().Value(userKey).(*User)
if !ok {
// Обробка помилки
}
Regexp-паттерни для параметрів (розширена валідація)
Chi підтримує регулярні вирази для валідації параметрів шляху:
// Тільки числові ID
r.Get("/orders/{orderID:[0-9]+}", getOrderHandler)
// UUID формат
r.Get("/users/{userID:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}", getUserHandler)
// Дати у форматі YYYY-MM-DD
r.Get("/reports/{date:[0-9]{4}-[0-9]{2}-[0-9]{2}}", getReportHandler)
// Slug (латинські літери, цифри, дефіси)
r.Get("/posts/{slug:[a-z0-9-]+}", getPostHandler)
Якщо параметр не відповідає regex, Chi поверне 404.
Практичний приклад: повний сервер з Chi
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Глобальні middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Use(middleware.Compress(5))
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
"time": time.Now().Format(time.RFC3339),
})
})
// API v1
r.Route("/api/v1", func(r chi.Router) {
r.Use(corsMiddleware)
// Публічні маршрути
r.Group(func(r chi.Router) {
r.Post("/auth/login", loginHandler)
r.Post("/auth/register", registerHandler)
})
// Захищені маршрути
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Route("/orders", func(r chi.Router) {
r.Get("/", listOrdersHandler)
r.Post("/", createOrderHandler)
r.Route("/{orderID:[0-9]+}", func(r chi.Router) {
r.Get("/", getOrderHandler)
r.Put("/", updateOrderHandler)
r.Delete("/", deleteOrderHandler)
})
})
})
})
// Користувацькі обробники помилок
r.NotFound(notFoundHandler)
r.MethodNotAllowed(methodNotAllowedHandler)
// Graceful Shutdown
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("🚀 Server starting on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Очікуємо сигнал завершення
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("⏳ Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("✅ Server stopped gracefully")
}
// Заглушки для обробників (реалізуйте власну логіку)
func loginHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func registerHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func listOrdersHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func createOrderHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func getOrderHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func updateOrderHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func deleteOrderHandler(w http.ResponseWriter, r *http.Request) { /* ... */ }
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "Route not found",
"path": r.URL.Path,
})
}
func methodNotAllowedHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{
"error": "Method not allowed",
})
}
- ✅ 100% сумісний з
http.Handler(не потребує власних типів) - ✅ Мінімалістичний (лише маршрутизація та middleware, без зайвих абстракцій)
- ✅ Швидкий (використовує Radix Tree для O(log n) пошуку маршруту)
- ✅ Стандартний (ідіоматичний Go код, без магії)
- ✅ Гнучкий (підтримка суб-роутерів, груп, контекстів)
Серіалізація та десеріалізація JSON
Базова робота з JSON
Стандартний пакет encoding/json надає зручні методи для роботи з JSON:
Десеріалізація запиту:
type CreateOrderRequest struct {
UserID string `json:"user_id"`
Recipe string `json:"recipe"`
Volume string `json:"volume"`
}
func createOrderHandler(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
// Декодуємо JSON з тіла запиту
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
// Валідація
if req.UserID == "" || req.Recipe == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// ... бізнес-логіка
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order_123",
"user_id": req.UserID,
"recipe": req.Recipe,
"status": "pending",
})
}
Теги структур JSON:
type Order struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Recipe string `json:"recipe"`
Volume string `json:"volume,omitempty"` // Опускається, якщо порожнє
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"-"` // Ніколи не серіалізується
}
Теги JSON:
json:"field_name"— встановлює ім'я поля у JSON.json:"field,omitempty"— опускає поле, якщо воно має нульове значення.json:"-"— ігнорує поле при серіалізації/десеріалізації.
Стандартизовані відповіді про помилки: RFC 7807 Problem Details
RFC 7807 визначає стандартний формат для опису помилок у HTTP API:
type ProblemDetails struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Extra map[string]interface{} `json:"-"`
}
func (p ProblemDetails) MarshalJSON() ([]byte, error) {
type Alias ProblemDetails
base := Alias(p)
m := make(map[string]interface{})
data, _ := json.Marshal(base)
json.Unmarshal(data, &m)
for k, v := range p.Extra {
m[k] = v
}
return json.Marshal(m)
}
func respondWithError(w http.ResponseWriter, problem ProblemDetails) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(problem.Status)
json.NewEncoder(w).Encode(problem)
}
// Використання
func getOrderHandler(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "orderID")
// Припустимо, замовлення не знайдено
if orderID == "999" {
respondWithError(w, ProblemDetails{
Type: "https://api.coffeeshop.dev/errors/order-not-found",
Title: "Order Not Found",
Status: http.StatusNotFound,
Detail: fmt.Sprintf("Order with ID '%s' does not exist", orderID),
Instance: r.URL.Path,
})
return
}
// ... повернення замовлення
}
Приклад відповіді про помилку:
{
"type": "https://api.coffeeshop.dev/errors/order-not-found",
"title": "Order Not Found",
"status": 404,
"detail": "Order with ID '999' does not exist",
"instance": "/v1/orders/999"
}
Clean Architecture у REST API: розділення відповідальностей
Шари Clean Architecture
Clean Architecture — це архітектурний патерн, запропонований Робертом Мартіном (Robert C. Martin / «Uncle Bob»), що передбачає розділення коду на шари з чіткими межами відповідальності:
1. Domain Layer (Домен):
Містить сутності предметної області без залежностей:
// internal/domain/order.go
package domain
import "time"
type Order struct {
ID string
UserID string
Recipe string
Volume string
Status OrderStatus
CreatedAt time.Time
UpdatedAt time.Time
}
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusPreparing OrderStatus = "preparing"
OrderStatusCompleted OrderStatus = "completed"
OrderStatusCancelled OrderStatus = "cancelled"
)
func (o *Order) CanBeCancelled() bool {
return o.Status == OrderStatusPending || o.Status == OrderStatusPreparing
}
2. Repository Layer (Репозиторій):
Інтерфейс доступу до даних:
// internal/repository/order_repository.go
package repository
import (
"context"
"myapp/internal/domain"
)
type OrderRepository interface {
FindAll(ctx context.Context, userID string) ([]domain.Order, error)
FindByID(ctx context.Context, id string) (domain.Order, error)
Create(ctx context.Context, order *domain.Order) error
Update(ctx context.Context, order *domain.Order) error
Delete(ctx context.Context, id string) error
}
Конкретна імплементація для PostgreSQL:
// internal/repository/postgres/order_repository.go
package postgres
import (
"context"
"myapp/internal/domain"
"github.com/jackc/pgx/v5/pgxpool"
)
type OrderRepository struct {
db *pgxpool.Pool
}
func NewOrderRepository(db *pgxpool.Pool) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) FindByID(ctx context.Context, id string) (domain.Order, error) {
var order domain.Order
query := `
SELECT id, user_id, recipe, volume, status, created_at, updated_at
FROM orders
WHERE id = $1
`
err := r.db.QueryRow(ctx, query, id).Scan(
&order.ID,
&order.UserID,
&order.Recipe,
&order.Volume,
&order.Status,
&order.CreatedAt,
&order.UpdatedAt,
)
if err != nil {
return domain.Order{}, err
}
return order, nil
}
func (r *OrderRepository) Create(ctx context.Context, order *domain.Order) error {
query := `
INSERT INTO orders (id, user_id, recipe, volume, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.db.Exec(ctx, query,
order.ID,
order.UserID,
order.Recipe,
order.Volume,
order.Status,
order.CreatedAt,
order.UpdatedAt,
)
return err
}
// ... інші методи
3. UseCase Layer (Бізнес-логіка):
// internal/usecase/order_usecase.go
package usecase
import (
"context"
"myapp/internal/domain"
"myapp/internal/repository"
"time"
"github.com/google/uuid"
)
type OrderUseCase interface {
GetOrder(ctx context.Context, id string) (domain.Order, error)
CreateOrder(ctx context.Context, dto CreateOrderDTO) (domain.Order, error)
}
type CreateOrderDTO struct {
UserID string
Recipe string
Volume string
}
type orderService struct {
repo repository.OrderRepository
}
func NewOrderService(repo repository.OrderRepository) OrderUseCase {
return &orderService{repo: repo}
}
func (s *orderService) GetOrder(ctx context.Context, id string) (domain.Order, error) {
return s.repo.FindByID(ctx, id)
}
func (s *orderService) CreateOrder(ctx context.Context, dto CreateOrderDTO) (domain.Order, error) {
// Бізнес-валідація
if dto.Recipe == "" {
return domain.Order{}, ErrInvalidRecipe
}
order := domain.Order{
ID: uuid.New().String(),
UserID: dto.UserID,
Recipe: dto.Recipe,
Volume: dto.Volume,
Status: domain.OrderStatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.repo.Create(ctx, &order); err != nil {
return domain.Order{}, err
}
return order, nil
}
4. Delivery Layer (HTTP Handlers):
// internal/delivery/http/order_handler.go
package http
import (
"encoding/json"
"net/http"
"myapp/internal/usecase"
"github.com/go-chi/chi/v5"
)
type OrderHandler struct {
useCase usecase.OrderUseCase
}
func NewOrderHandler(useCase usecase.OrderUseCase) *OrderHandler {
return &OrderHandler{useCase: useCase}
}
func (h *OrderHandler) RegisterRoutes(r chi.Router) {
r.Get("/orders/{id}", h.Get)
r.Post("/orders", h.Create)
}
func (h *OrderHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := h.useCase.GetOrder(r.Context(), id)
if err != nil {
http.Error(w, "Order not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID string `json:"user_id"`
Recipe string `json:"recipe"`
Volume string `json:"volume"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
dto := usecase.CreateOrderDTO{
UserID: req.UserID,
Recipe: req.Recipe,
Volume: req.Volume,
}
order, err := h.useCase.CreateOrder(r.Context(), dto)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
Dependency Injection: зв'язування шарів
Точка входу (main.go):
// cmd/server/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"myapp/internal/delivery/http/handler"
"myapp/internal/repository/postgres"
"myapp/internal/usecase"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
// Підключення до бази даних
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
log.Fatalf("Unable to connect to database: %v", err)
}
defer pool.Close()
// Ініціалізація шарів (Dependency Injection)
orderRepo := postgres.NewOrderRepository(pool)
orderUseCase := usecase.NewOrderService(orderRepo)
orderHandler := handler.NewOrderHandler(orderUseCase)
// Налаштування роутера
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.Timeout(60 * time.Second))
// Реєстрація маршрутів
r.Get("/health", healthCheckHandler)
r.Route("/v1", func(r chi.Router) {
orderHandler.RegisterRoutes(r)
})
// Налаштування сервера з Graceful Shutdown
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Запуск сервера у горутині
go func() {
log.Printf("🚀 Server starting on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Очікування сигналу завершення
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("⏳ Shutting down server...")
// Graceful Shutdown з таймаутом 30 секунд
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("✅ Server stopped gracefully")
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy","timestamp":"` + time.Now().Format(time.RFC3339) + `"}`))
}
main().Тестування HTTP-обробників засобами httptest
Стандартний пакет net/http/httptest дозволяє тестувати HTTP-обробники без запуску реального сервера:
// internal/delivery/http/order_handler_test.go
package http_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"myapp/internal/delivery/http/handler"
"myapp/internal/domain"
"myapp/internal/usecase"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock UseCase
type MockOrderUseCase struct {
mock.Mock
}
func (m *MockOrderUseCase) GetOrder(ctx context.Context, id string) (domain.Order, error) {
args := m.Called(ctx, id)
return args.Get(0).(domain.Order), args.Error(1)
}
func (m *MockOrderUseCase) CreateOrder(ctx context.Context, dto usecase.CreateOrderDTO) (domain.Order, error) {
args := m.Called(ctx, dto)
return args.Get(0).(domain.Order), args.Error(1)
}
func TestOrderHandler_Get(t *testing.T) {
// Arrange
mockUseCase := new(MockOrderUseCase)
h := handler.NewOrderHandler(mockUseCase)
expectedOrder := domain.Order{
ID: "order_123",
UserID: "user_456",
Recipe: "espresso",
Status: domain.OrderStatusCompleted,
}
mockUseCase.On("GetOrder", mock.Anything, "order_123").Return(expectedOrder, nil)
// Створюємо запит
req := httptest.NewRequest(http.MethodGet, "/orders/order_123", nil)
rec := httptest.NewRecorder()
// Налаштовуємо роутер з параметрами шляху
r := chi.NewRouter()
r.Get("/orders/{id}", h.Get)
// Act
r.ServeHTTP(rec, req)
// Assert
assert.Equal(t, http.StatusOK, rec.Code)
var response domain.Order
err := json.NewDecoder(rec.Body).Decode(&response)
assert.NoError(t, err)
assert.Equal(t, expectedOrder.ID, response.ID)
assert.Equal(t, expectedOrder.Recipe, response.Recipe)
mockUseCase.AssertExpectations(t)
}
func TestOrderHandler_Create(t *testing.T) {
// Arrange
mockUseCase := new(MockOrderUseCase)
h := handler.NewOrderHandler(mockUseCase)
requestBody := map[string]string{
"user_id": "user_456",
"recipe": "cappuccino",
"volume": "200ml",
}
body, _ := json.Marshal(requestBody)
expectedOrder := domain.Order{
ID: "order_789",
UserID: "user_456",
Recipe: "cappuccino",
Status: domain.OrderStatusPending,
}
mockUseCase.On("CreateOrder", mock.Anything, mock.MatchedBy(func(dto usecase.CreateOrderDTO) bool {
return dto.UserID == "user_456" && dto.Recipe == "cappuccino"
})).Return(expectedOrder, nil)
req := httptest.NewRequest(http.MethodPost, "/orders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/orders", h.Create)
// Act
r.ServeHTTP(rec, req)
// Assert
assert.Equal(t, http.StatusCreated, rec.Code)
var response domain.Order
json.NewDecoder(rec.Body).Decode(&response)
assert.Equal(t, expectedOrder.ID, response.ID)
mockUseCase.AssertExpectations(t)
}
Інтеграція з гетерогенними клієнтами
Десктопний клієнт на JavaFX (Java)
// OrderService.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import com.google.gson.Gson;
public class OrderService {
private final HttpClient client = HttpClient.newHttpClient();
private final Gson gson = new Gson();
private final String baseUrl = "http://localhost:8080/v1";
public Order getOrder(String orderId) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/orders/" + orderId))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return gson.fromJson(response.body(), Order.class);
} else {
throw new RuntimeException("Order not found");
}
}
public Order createOrder(CreateOrderRequest req) throws Exception {
String json = gson.toJson(req);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/orders"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
return gson.fromJson(response.body(), Order.class);
}
}
Мобільний клієнт на React Native (JavaScript)
// orderService.js
const API_BASE_URL = 'http://localhost:8080/v1';
export const orderService = {
async getOrder(orderId) {
const response = await fetch(`${API_BASE_URL}/orders/${orderId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('Order not found');
}
return await response.json();
},
async createOrder(orderData) {
const response = await fetch(`${API_BASE_URL}/orders`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(orderData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create order');
}
return await response.json();
},
async listOrders(userId) {
const response = await fetch(
`${API_BASE_URL}/orders?user_id=${userId}`,
{
method: 'GET',
headers: {
'Accept': 'application/json',
},
}
);
return await response.json();
},
};
CLI-утиліта на Go
// cmd/cli/main.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const baseURL = "http://localhost:8080/v1"
type Order struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Recipe string `json:"recipe"`
Status string `json:"status"`
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: cli [get|create|list] [args...]")
os.Exit(1)
}
command := os.Args[1]
switch command {
case "get":
if len(os.Args) < 3 {
fmt.Println("Usage: cli get <order_id>")
os.Exit(1)
}
getOrder(os.Args[2])
case "create":
createOrder()
case "list":
if len(os.Args) < 3 {
fmt.Println("Usage: cli list <user_id>")
os.Exit(1)
}
listOrders(os.Args[2])
default:
fmt.Println("Unknown command:", command)
}
}
func getOrder(orderID string) {
resp, err := http.Get(fmt.Sprintf("%s/orders/%s", baseURL, orderID))
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Println("Order not found")
return
}
var order Order
json.NewDecoder(resp.Body).Decode(&order)
fmt.Printf("Order: %+v\n", order)
}
func createOrder() {
reqBody := map[string]string{
"user_id": "user_123",
"recipe": "latte",
"volume": "300ml",
}
body, _ := json.Marshal(reqBody)
resp, err := http.Post(
fmt.Sprintf("%s/orders", baseURL),
"application/json",
bytes.NewReader(body),
)
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
var order Order
json.NewDecoder(resp.Body).Decode(&order)
fmt.Printf("Created order: %+v\n", order)
}
func listOrders(userID string) {
resp, err := http.Get(fmt.Sprintf("%s/orders?user_id=%s", baseURL, userID))
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
var orders []Order
json.NewDecoder(resp.Body).Decode(&orders)
for _, order := range orders {
fmt.Printf("- %s: %s (%s)\n", order.ID, order.Recipe, order.Status)
}
}
Підсумки та ключові висновки
✅ Що ми вивчили
- Еволюцію від RPC до HTTP API: Зрозуміли, чому HTTP став універсальною мовою інтернету та як REST-принципи допомагають будувати масштабовані системи.
- Архітектурний стиль REST: Розібралися з дисертацією Філдінга, міфологією REST та практичним визначенням JSON-over-HTTP API.
- Складові HTTP: Детально вивчили URL, заголовки, HTTP-методи, статус-коди та їхню семантику.
- Проєктування номенклатури URL: Навчилися організовувати ієрархію ресурсів, дотримуватися конвенцій найменування та реалізовувати CRUD-операції.
- Маршрутизатор Chi: Опанували параметри шляху, групування маршрутів, middleware та чисту інтеграцію зі стандартною бібліотекою.
- Clean Architecture: Побачили розділення на шари Domain, Repository, UseCase, Delivery та ручне впровадження залежностей.
- Інтеграцію з клієнтами: Навчилися взаємодіяти з гетерогенними клієнтами (Desktop, Mobile, CLI).
🎯 Практичні навички
- Проєктування RESTful API з дотриманням принципів ідемпотентності та stateless-дизайну.
- Використання маршрутизатора Chi для організації складних маршрутів із middleware.
- Серіалізація/десеріалізація JSON та формування стандартизованих відповідей про помилки (RFC 7807).
- Організація коду за принципами Clean Architecture для підтримки масштабованості.
- Тестування HTTP-обробників засобами
httptestта мокування залежностей. - Забезпечення інтероперабельності API з різними платформами клієнтів.
http.ServeMux має обмежену функціональність: немає параметрів шляху (/orders/{id}), немає middleware, складно організувати групування маршрутів. Chi вирішує всі ці проблеми, залишаючись сумісним з http.Handler та не додаючи надлишкової «магії».POST обробляє запит «відповідно до внутрішньої семантики ресурсу». Це означає, що повторний виклик POST /orders може створити два різні замовлення. Натомість PUT /orders/123 замінює ресурс з фіксованим ID 123 — повторний виклик залишає ресурс у тому самому стані.- PUT: Коли ви хочете повністю замінити ресурс. Усі поля, не передані у запиті, мають бути скинуті до значень за замовчуванням.
- PATCH: Коли ви хочете частково оновити ресурс. Оновлюються лише передані поля, інші залишаються без змін.
На практиці PATCH використовується частіше, оскільки клієнту зручніше відправляти тільки змінені поля.
Розміщення версії у шляху (/v1/orders) є найпопулярнішим підходом, оскільки:
- Версія видна одразу у URL, що полегшує налагодження.
- Різні версії API можуть бути легко проксовані на різні сервери.
- URL чітко показує, що це різні API, навіть якщо вони обслуговуються одним сервером.
Альтернативи (заголовок X-API-Version: 1 або параметр запиту ?version=1) менш інтуїтивні та гірше підтримуються інструментами.
Наступна лекція: Шарувата архітектура та конвеєри обробки запитів (Middleware Pipeline, Clean Architecture, Dependency Injection, тестування HTTP-шару).
Робота з помилками у HTTP API: стандартизовані підходи
Проблема обробки помилок
Розглянемо типовий запит створення замовлення:
POST /v1/orders?user_id=user_123 HTTP/1.1
Authorization: Bearer <token>
If-Match: <revision>
{ "recipe": "espresso", "volume": "50ml" }
Які потенційні помилки можуть виникнути?
- Запит не може бути прочитаний (некоректний JSON, порушення синтаксису).
- Токен авторизації відсутній.
- Токен авторизації невалідний або прострочений.
- Токен валідний, але користувач не має прав створювати замовлення.
- Користувач видалений або деактивований.
- Ідентифікатор користувача неправильний (не існує).
- Ревізія не передана або не збігається з актуальною.
- У тілі запиту відсутні обов'язкові поля.
- Якесь поле має недопустиме значення.
- Перевищено ліміти на кількість запитів (Rate Limit).
- Сервер перевантажений і не може відповісти зараз.
- Невідома серверна помилка (критичний збій).
Спокуса призначити кожній помилці свій статус-код здається очевидною, але перш ніж це робити, треба відповісти на питання: з якою метою ми хочемо призначити той чи інший код?
Три актори системи та їхні потреби
У системі присутні три актори: користувач застосунку, клієнтський застосунок та сервер. Кожному з них потрібно розуміти чотири аспекти помилки:
- Хто допустив помилку? Користувач, розробник клієнта, розробник сервера чи проміжний агент (наприклад, проксі)?
- Чи можна виправити помилку простим повторенням запиту? Якщо так, через який час?
- Чи можна виправити помилку переформулюванням запиту? Які саме зміни потрібні?
- Якщо помилку неможливо виправити, що робити? Як інформувати користувача?
HTTP частково допомагає відповісти на перше питання через першу цифру статус-коду (4xx — клієнтська помилка, 5xx — серверна). Для регулювання часу повторного запиту можна використовувати заголовок Retry-After та параметри кешування. Але з іншими питаннями ситуація складніша.
Клієнтські помилки (4xx): недостатність стандартних кодів
Статус-коди 4xx індикують, що помилка допущена користувачем або клієнтом. Зазвичай отриману 4xx повторювати безглуздо — без додаткових дій для зміни стану сервісу цей запит ніколи не буде виконано успішно.
Виняток: 429 Too Many Requests (варто повторити через деякий час) та 404 Not Found (може бути «станом невизначеності» — сервер може не розкривати причину помилки).
Проблема: Номенклатура HTTP-кодів недостатня для опису помилок бізнес-логіки. Наприклад:
400 Bad Requestвикористовується для будь-яких помилок валідації (некоректний JSON, відсутні поля, недопустимі значення).403 Forbiddenдля будь-яких проблем авторизації.404 Not Foundколи ресурс не знайдено або коли сервер не хоче розкривати причину.409 Conflictпри порушенні цілісності даних.422 Unprocessable Entityколи запит синтаксично коректний, але семантично помилковий.429 Too Many Requestsпри перевищенні лімітів (але немає стандартного способу вказати, який саме ліміт перевищено).
Проблема ідентифікації типу помилки
HTTP надає спеціалізовані коди для проблем із самим протоколом:
405 Method Not Allowed— даний метод не застосовний до вказаного ресурсу.406 Not Acceptable— сервер не може повернути відповідь згідно зAccept-заголовками.411 Length Required— відсутній заголовокContent-Length.414 URI Too Long— URL занадто довгий.415 Unsupported Media Type— непідтримуванийContent-Type.
Але всі вони погано застосовні до помилок бізнес-логіки. Ми не можемо повернути 429 Too Many Requests при перевищенні різних лімітів та розрізнити їх: чи це ліміт на кількість замовлень на день, чи ліміт на кількість запитів на хвилину, чи ліміт на розмір замовлення.
De-facto стандарти vs формальна специфікація
Сьогодні де-факто стандартом є повернення коду 401 Unauthorized при відсутності заголовків авторизації або невалідному токені (отримання цього коду є сигналом для застосунку запропонувати користувачу залогінитися). Це суперечить формальній специфікації, яка вимагає при поверненні 401 обов'язково вказувати заголовок WWW-Authenticate з описом способу автентифікації користувача.
WWW-Authenticate при поверненні 401. Розробники API використовують 401 для індикації «потрібна автентифікація», а не «потрібна автентифікація за конкретною схемою».Розширений формат опису помилок: RFC 7807 та практичні розширення
Рішення: Використовувати статус-код згідно з його семантикою для індикації роду помилки та вкладені метадані/тіло у спеціально розробленому форматі для деталізації.
Розробники стандарту HTTP усвідомлюють цю проблему та рекомендують передавати у метаданих або тілі відповіді додаткові дані для опису помилки:
"The server SHOULD send a representation containing an explanation of the error situation, and whether it is a temporary or permanent condition."
Стандарт RFC 7807 (Problem Details for HTTP APIs) запропонував формат JSON-опису HTTP-помилок, але він покриває лише базовий сценарій:
- Підтип помилки не передається у метаінформації.
- Немає розділення на повідомлення для користувача та повідомлення для розробника.
- Конкретний машино-читабельний формат опису помилок залишається на розсуд розробника.
Детальний приклад: складна валідаційна помилка
Розглянемо реалістичний приклад пошуку кавоварок із множинними помилками валідації:
POST /v1/coffee-machines/search HTTP/1.1
{
"recipes": ["lngo"],
"position": {
"latitude": 110,
"longitude": 55
}
}
→ HTTP/1.1 400 Bad Request
X-CoffeeAPI-Error-Kind: validation_failed
Content-Type: application/problem+json
{
"type": "https://api.coffeeshop.dev/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"detail": "Request contains invalid field values",
"instance": "/v1/coffee-machines/search",
"errors": [
{
"field": "recipes[0]",
"error_type": "unknown_value",
"message": "Value 'lngo' is unknown. Did you mean 'lungo'?",
"hint": "lungo"
},
{
"field": "position.latitude",
"error_type": "constraint_violation",
"message": "Latitude must be between -90 and 90",
"constraints": {
"min": -90,
"max": 90
}
}
]
}
Переваги такого підходу:
- Машино-читабельність: Клієнт може програмно обробити кожну помилку валідації окремо.
- Людино-читабельність: Розробник одразу бачить усі проблеми у читабельному форматі.
- Відновлюваність: Клієнт знає, які саме поля виправити.
- Розширюваність: Можна додавати нові типи помилок без зміни базового формату.
Серверні помилки (5xx): мінімалістичний підхід
Помилки 5xx індикують, що клієнт виконав запит правильно, але проблема на стороні сервера. Для клієнта важливо лише:
- Чи має сенс повторювати запит?
- Якщо так, через який час?
Для публічних API причини серверних помилок зазвичай не розкриваються. Достатньо двох кодів:
500 Internal Server Error— загальна серверна помилка.503 Service Unavailable— сервіс тимчасово недоступний, можна повторити запит через деякий час.
Для внутрішніх систем серверні помилки повинні містити підтип помилки у машино-читабельному вигляді для побудови правильних моніторингів та систем оповіщення.
Приклад для внутрішнього API:
POST /v1/orders?user_id=user_123 HTTP/1.1
If-Match: <revision>
→ HTTP/1.1 500 Internal Server Error
X-CoffeeAPI-Error-Kind: database_timeout
Retry-After: 5
{
"type": "https://internal.coffeeshop.dev/errors/database-timeout",
"title": "Database Timeout",
"status": 500,
"detail": "Database query exceeded 5s timeout",
"instance": "/v1/orders",
"database_host": "postgres-primary-01.internal"
}
Приклад для публічного API (після обробки шлюзом):
→ HTTP/1.1 500 Internal Server Error
Retry-After: 5
Content-Type: application/problem+json
{
"type": "https://api.coffeeshop.dev/errors/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred. Please try again later.",
"instance": "/v1/orders",
"can_retry": true,
"localized_message": "Не вдалося отримати відповідь від сервера. Спробуйте повторити операцію через кілька секунд."
}
Три стратегії обробки помилок у HTTP API
- Розширювальна трактовка статус-кодів: Використовувати новий код для кожного виду помилки (часто порушуючи стандарт).
- Відмова від статус-кодів: Завжди повертати
200 OKта вкладати опис помилки у тіло відповіді (підхід більшості RPC-фреймворків).- Варіант 2a: Використовувати тільки два коди (
400для клієнтських,500для серверних), опціонально три (+404для стану невизначеності).
- Варіант 2a: Використовувати тільки два коди (
- Змішаний підхід (рекомендований): Використовувати статус-код згідно з семантикою для індикації роду помилки та вкладені метадані/тіло для деталізації.
Практичні рекомендації та best practices
Code Style та конвенції
Чеклист розробки якісного HTTP API:
Крок 1: Описати Happy Path
Створіть діаграму викликів для стандартного циклу роботи клієнтського застосунку. Визначте послідовність операцій, які користувач виконує найчастіше.
Крок 2: Визначити ресурси та операції
Визначте кожен виклик як операцію над певним ресурсом. Складіть номенклатуру URL та застосовних методів. Переконайтеся, що URL відображають ієрархію ресурсів.
Крок 3: Описати сценарії помилок
Зрозумійте, які помилки можливі при виконанні кожної операції та яким чином клієнт має відновлюватися. Складіть матрицю: помилка → дія клієнта → очікуваний результат.
Крок 4: Розподілити функціональність
Вирішіть, яка функціональність буде передана на рівень протоколу HTTP (використання стандартних можливостей протоколу) та в якому обсязі. Визначте, які інструменти розробки будуть використані для кожної можливості.
Крок 5: Розробити специфікацію
На основі рішень із кроків 1-4 розробіть конкретну специфікацію API у форматі OpenAPI 3.0 або аналогічному.
Крок 6: Перевірити зручність
Пройдіть по пунктах 1-3, напишіть псевдокод бізнес-логіки клієнтського застосунку згідно з розробленою специфікацією. Оцініть, наскільки зручним, зрозумілим та читабельним виявився результат.
Конвенції найменування та форматування
- Уніфікація слешів у кінці URL:
- Не розрізняйте
/ordersта/orders/— вони мають вести на той самий ресурс. - Рекомендація: завжди завершуйте шляхи на
/(дозволяє розумно описати звернення до кореня домену якGET /). - При зверненні до неправильного варіанту повертайте редирект (
301 Moved Permanently) або зрозумілу помилку.
- Не розрізняйте
- Завжди повертайте JSON-об'єкт як корінь:
- ✅ Правильно:
{"users": [...], "total": 150, "page": 1} - ❌ Неправильно:
[...](масив у корені)
Причина: До об'єкта можна додати нові поля (наприклад, метадані пагінації) без порушення зворотної сумісності. До масиву або примітиву додати поля неможливо. - ✅ Правильно:
- Порожній об'єкт замість порожнього тіла:
- ✅ Правильно:
{}(валідний JSON) або204 No Contentбез тіла - ❌ Неправильно: порожня стрічка (не є валідним JSON)
Примітка: Порожня стрічка не є валідним JSON-документом згідно зі специфікацією. - ✅ Правильно:
- Консистентність кейсингу:
kebab-caseдля частин шляху:/coffee-machines,/user-profilessnake_caseдля query-параметрів:?user_id=123&order_status=pendingsnake_caseабоcamelCaseдля JSON-полів (обирайте один стиль та дотримуйтесь його)
Правило трансформації: Якщо параметр переміщується між різними секціями запиту (з path у query, з query у body), кейсинг може змінюватися відповідно до конвенцій секції, але ім'я параметра залишається тим самим (user_idзавждиuser_id, а неuserIdабоUserID). - Не використовуйте спеціальні символи у path та домені:
- ❌ Неправильно:
/orders/user@example.com(потребує екранування) - ✅ Правильно:
/orders?user_email=user@example.comабо/ordersзuser_emailу тілі
Причина: Символи, які потребують екранування (,,/,?,#,@,&,=), створюють проблеми при парсингу URL. - ❌ Неправильно:
- Не передавайте масиви та вкладені об'єкти через query-параметри:
- ❌ Неправильно:
?filters[0][field]=name&filters[0][value]=espresso - ✅ Правильно: використайте
POSTз тілом запиту або Base64-кодований JSON у одному параметрі (останнє — крайній захід).
- ❌ Неправильно:
Обов'язкові HTTP-заголовки
Завжди включайте у відповіді наступні стандартні заголовки:
Date— таймстемп створення відповіді для синхронізації годинників:Date: Tue, 01 Sep 2026 14:30:00 GMTContent-Type— MIME-тип вмісту відповіді:Content-Type: application/json; charset=utf-8Content-Length— розмір тіла відповіді у байтах:Content-Length: 1247
Важливо: Деякі проксі взагалі не пропускають відповіді безContent-Length.Content-Encoding— використане стиснення (якщо застосовується):Content-Encoding: gzipCache-Control— політика кешування для GET-запитів (детальніше нижче).Retry-After— рекомендований час повторного запиту при помилках429,503:Retry-After: 60
Підтримка CORS для браузерних клієнтів
Для публічних API, які мають використовуватися з браузерів, обов'язково реалізуйте підтримку CORS (Cross-Origin Resource Sharing):
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
// Для продакшену вказуйте конкретні домени замість "*"
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Request-ID")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400") // 24 години
}
// Обробка preflight-запитів
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Access-Control-Allow-Origin: *, але для API з автентифікацією краще вказувати конкретні домени.Причина: Символи, які потребують екранування (,, /, ?, #, @, &, =), створюють проблеми при парсингу URL.
- Не передавайте масиви та вкладені об'єкти через query-параметри:
- ❌ Неправильно:
?filters[0][field]=name&filters[0][value]=espresso - ✅ Правильно: використайте
POSTз тілом запиту або Base64-кодований JSON у одному параметрі (останнє — крайній захід). Завжди включайте у відповіді: Date— таймстемп відповіді для синхронізації годинників.Content-Type— тип вмісту (application/json,text/plain).Content-Length— розмір тіла відповіді.Cache-Control— політика кешування.Retry-After— рекомендований час повторного запиту при помилках.
- ❌ Неправильно:
- Підтримка CORS для браузерів:
Реалізуйте метод
OPTIONSдля preflight-запитів та додайте заголовки:Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization - Консистентність кейсингу:
kebab-caseдля шляху:/coffee-machines,/user-profilessnake_caseдля query-параметрів та JSON-полів:?user_id=123,{"order_id": 456}- Дотримуйтесь обраного стилю в усьому API.
- Завжди повертайте JSON-об'єкт як корінь:
- ✅ Правильно:
{"users": [...]} - ❌ Неправильно:
[...](масив у корені)
Причина: До об'єкта можна додати нові поля без порушення сумісності, до масиву — ні. - ✅ Правильно:
- Пустий об'єкт замість порожнього тіла:
- ✅ Правильно:
{}або204 No Contentбез тіла - ❌ Неправильно: порожня стрічка (не є валідним JSON)
- ✅ Правильно:
Правила безпечного використання HTTP
- Не розміщуйте модифікуючі операції за методом
GET. - Не розміщуйте неідемпотентні операції за
PUT/DELETE. - Дотримуйтесь симетрії
GET/PUT/DELETEметодів. - Не дозволяйте методам
GET/HEAD/DELETEмати тіло запиту. - Не повертайте тіло у відповіді на
HEADабо зі статусом204 No Content.
Безпека HTTP API: захист від типових вразливостей
- CSRF (Cross-Site Request Forgery):
- Використовуйте CSRF-токени для state-changing операцій.
- Перевіряйте заголовок
OriginабоReferer. - Для API з JWT-автентифікацією CSRF менш критичний (токен не надсилається автоматично браузером).
- SSRF (Server-Side Request Forgery):
- Валідуйте всі URL, які клієнт може передати серверу.
- Блокуйте запити до приватних IP-діапазонів (
127.0.0.1,10.0.0.0/8,192.168.0.0/16).
- HTTP Response Splitting:
- Очищайте всі заголовки від символів
\rта\n. - Використовуйте безпечні бібліотеки для встановлення заголовків.
- Очищайте всі заголовки від символів
- Unvalidated Redirects:
- Ніколи не редиректьте на URL, який контролюється клієнтом, без валідації.
- Використовуйте whitelist дозволених доменів для редиректів.
- Ін'єкції (SQL, NoSQL, Command Injection):
- Завжди використовуйте параметризовані запити.
- Ніколи не конкатенуйте вхідні дані користувача безпосередньо у SQL-запити.
- Хешування паролів:
- Використовуйте
bcrypt,argon2абоscrypt— ніколи неMD5чиSHA-1. - Не зберігайте паролі у відкритому вигляді навіть у логах.
- Використовуйте
Політика кешування для GET-запитів
Завжди вказуйте політику кешування для GET-запитів, інакше клієнт або проміжний агент можуть придумати її самостійно:
GET /v1/orders/123 HTTP/1.1
→ HTTP/1.1 200 OK
Cache-Control: max-age=300, private
ETag: "v123abc"
{ "id": "order_123", "status": "completed" }
Директиви Cache-Control:
max-age=<seconds>— максимальний час кешування.no-cache— необхідно перевірити актуальність перед використанням кешу (черезETag).no-store— ніколи не кешувати (для чутливих даних).public— може бути закешовано проміжними проксі.private— може бути закешовано лише на клієнті, не на проксі.
Заключні висновки: коли використовувати HTTP API
✅ HTTP API підходить для:
- Публічних API — максимальна інтероперабельність з будь-якими клієнтами.
- MVP та прототипів — швидка розробка, величезна екосистема інструментів.
- Вебклієнтів — нативна підтримка у браузерах.
- Систем з гетерогенними клієнтами — Desktop, Mobile, CLI, IoT.
- API з складною бізнес-логікою — читабельність та зрозумілість URL/методів.
⚙️ gRPC/GraphQL підходять для:
- Внутрішньої міжсервісної взаємодії — висока продуктивність.
- Систем реального часу — bidirectional streaming.
- Строго типізованих контрактів — кодогенерація клієнтів.
- Складних запитів до графів даних — GraphQL для frontend-driven API.
Наступна лекція: Шарувата архітектура та конвеєри обробки запитів — Middleware Pipeline, патерн Repository, Dependency Injection та тестування HTTP-шару.
Політика кешування для GET-запитів
Завжди вказуйте політику кешування для GET-запитів, інакше клієнт або проміжний агент можуть придумати її самостійно, що призведе до непередбачуваної поведінки:
GET /v1/orders/123 HTTP/1.1
→ HTTP/1.1 200 OK
Cache-Control: max-age=300, private
ETag: "v123abc"
Last-Modified: Tue, 01 Sep 2026 14:00:00 GMT
{ "id": "order_123", "status": "completed" }
Директиви Cache-Control:
max-age=<seconds>— максимальний час кешування у секундах.no-cache— необхідно перевірити актуальність перед використанням кешу (черезETagабоLast-Modified).no-store— ніколи не кешувати (для чутливих даних, як-от токени, паролі, персональні дані).public— може бути закешовано будь-яким проміжним кешем (CDN, проксі).private— може бути закешовано лише на клієнті, не на проміжних проксі.must-revalidate— після закінченняmax-ageобов'язково перевірити актуальність на сервері.
Приклади політик для різних типів даних:
// Статичні дані (профіль користувача)
w.Header().Set("Cache-Control", "max-age=3600, private")
// Динамічні дані (список замовлень)
w.Header().Set("Cache-Control", "no-cache, private")
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, generateETag(orders)))
// Чутливі дані (токени, паролі)
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache") // Для HTTP/1.0 клієнтів
// Публічні статичні ресурси (аватари, логотипи)
w.Header().Set("Cache-Control", "max-age=86400, public")
404 Not Foundкешується більшістю браузерів та проксі. Якщо ваше API може повернути 404 для ресурсу, який може з'явитися пізніше (наприклад, асинхронна обробка завдання), обов'язково вказуйте Cache-Control: no-cache або Cache-Control: max-age=0, щоб уникнути кешування відсутності ресурсу.Умовні запити для оптимізації трафіку
Використовуйте ETag та умовні заголовки для зменшення трафіку:
Сервер генерує ETag:
func getOrderHandler(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "id")
order, err := getOrderFromDB(orderID)
if err != nil {
http.Error(w, "Order not found", http.StatusNotFound)
return
}
// Генеруємо ETag на основі хеша даних або version field
etag := fmt.Sprintf(`"%s-%d"`, order.ID, order.UpdatedAt.Unix())
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache, private")
// Перевіряємо If-None-Match
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}
Клієнт використовує ETag при повторному запиті:
GET /v1/orders/123 HTTP/1.1
If-None-Match: "order_123-1725196800"
→ HTTP/1.1 304 Not Modified
ETag: "order_123-1725196800"
(порожнє тіло — клієнт використовує закешовану версію)
Протокол HTTP та стандартний вебсервер Go
Еволюція протоколу HTTP від версії 1.0 до HTTP/3 з транспортом QUIC, детальний розбір анатомії HTTP-запитів та відповідей (методи, статус-коди, заголовки, тіло), принципи ідемпотентності та безпеки методів, стандартний пакет net/http у Go (інтерфейс http.Handler, роутер http.ServeMux, кастомний http.Server), налаштування таймаутів, TLS та обробка потокових даних.
Лекція 10. Шарувата архітектура та конвеєри обробки запитів
Патерн Middleware у Go, принципи Clean Architecture, впровадження залежностей та модульне тестування HTTP-шару