REST API та обробка помилок
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою технологією, як REST API та розкрити теми, пов’язані з обробкою помилок та виняткових ситуацій. Я вже розповідав про конвенції в REST API щодо URI та HTTP методів. У цій статті я хотів би продовжити цю тему на прикладі обробки помилок. Постійно зіштовхуючись із цією темою, у мене набралося достатньо матеріалу для окремої статті.
Крім того, ми розглядаємо цей матеріал на деяких тренінгах. Ця тема досить цікава, і я прокоментую її конкретними практичними прикладами.
Обробка помилок та HTTP
Припустимо, що розробляєте новий проєкт на базі REST API і замислилися над тим, у якому форматі повертати клієнту повідомлення про помилки. Зрозуміло, якщо у вас RESTful вебсервіси, то ви використовуєте протокол HTTP і повинні повернути числовий статус-код про результат операції. Наприклад, коди з групи 2xx свідчать про успіх, а 4xx та 5xx — про невдачу.Але статус-код не містить достатньої інформації про природу помилки. Більш того, коди помилок у додатках відносяться до бізнес-функціональності вашого проєкту. Ви можете захотіти розширити цей список, додати або видалити непотрібні коди. Як спроєктувати всі ці значення на статус-код, який є інфраструктурним і не повинен залежати від тонкощів кожного проєкту?
Розглянемо приклад з банківським додатком, коли клієнт хоче зняти гроші з рахунку (картки) через мобільний додаток, який, у свою чергу, звертається до сервера через REST API. Якщо на рахунку недостатньо грошей, рекомендується повертати код 403 (Forbidden). Але як клієнтський додаток має відреагувати на такий результат? Адже цей код може бути повернутий і у разі, якщо:
- Рахунок закритий або заблокований.
- Користувач не має прав на зняття грошей.
- Клієнт хоче зняти суму, що перевищує ліміт на щомісячне користування.
І багато інших. Таким чином, потрібна додаткова інформація. Чому ж такого стандарту не було додано? Справа в тому, що початкова специфікація протоколу HTTP, випущена в 1996 році, не містила ніякого формату повідомлення про помилки. Втім, вона й не мала цього робити. Адже в 1996 році ніякого REST API не було, а сам HTTP використовувався тільки для сайтів (вебресурсів), і сервер у разі помилки просто повертав сторінку, де користувач повідомляв про помилку.
. Що залишається робити користувачеві? Перший варіант — придумати свій формат. Одна з найпопулярніших конструкцій (візьмемо JSON варіант):
{
"errorCode": 15,
"errorDescription": "Invalid account number"
}
Тут errorCode — код помилки, специфічний для додатка. При цьому статус-код у цій відповіді може бути 200, що означає, що клієнтська програма повинна орієнтуватися тільки на властивість errorCode. Щоправда, тут незрозуміло, що є ознакою успіху? Яке значення errorCode? 0, −1, пусте значення? Тому зустрічається і просунутіший варіант з власністю success:
{
"success": false,
"error": {
"code": 105,
"message": "Invalid user role",
"description": "Create user operation support only user or admin roles"
}
}
Такий формат теж має недоліки. Наприклад, якщо помилок декілька? Чи наскільки легко розширити такий формат? Можливо, можна знайти стандарти у популярних соцмережах, які пройшли довгий шлях у покращенні свого API.
Обробка помилок у соцмережах
Візьмемо для прикладу кілька лідерів ІТ-галузі та почнемо з Twtter, куди відправимо запит, у якому пропущено username: api.twitter.com/2/users/by?usernames=
Він повертає код 403 та наступний JSON:
{
"title": "Unauthorized",
"type": "about:blank",
"status": 401,
"detail": "Unauthorized"
}
Тепер візьмемо Facebook та запит, який використовує некоректну назву поля: graph.facebook.com/v15.0/me?fields=salary
У відповідь отримаємо:
{
"error": {
"message": "(#100) Tried accessing nonexisting field (salary) on node type (User)",
"type": "OAuthException",
"code": 100,
"fbtrace_id": "A_Q40DicmW-NkTOzIPDp2Fh
"
}
}
І насамкінець запит у GitHub API з неправильним параметром: api.github.com/search/issues?query=rest
У відповідь отримаємо:
{
"message": "Validation Failed",
"errors": [
{
"resource": "Search",
"field": "q",
"code": "missing"
}
],
"documentation_url": "https://docs.github.com/v3/search"
}
Як ви бачите, у кожному варіанті використовується свій формат, і якщо ви використовуєте інтеграцію з цими соцмережами, потрібно проробляти додатковий обсяг роботи для кожного нового API. А ще є версіонування API, і всі ці формати можуть змінюватися від версії до версії.
Обробка помилок у Spring MVC
Може, нам підійде формат, що використовується в Spring Framework? Візьмемо найпростіший Spring MVC додаток на базі Spring Boot 2.7.5 і додамо DTO:
public record BookDTO(int id, @NotEmpty String name) {
}
І також REST-сервіс, який його зберігає:
@PostMapping
public void saveBook(@RequestBody @Valid BookDTO book) {
У BookDTO поле name містить анотацію @NotEmpty з Bean Validation API, тому якщо надіслати на цей REST-сервіс порожній JSON, то в результаті отримаємо стандартну для Spring MVC відповідь, де, до речі, дублюється HTTP статус-код:
{
"timestamp": "2022-11-18T19:16:00.459+00:00",
"status": 400,
"error": "Bad Request",
"path": "/books"
}
Такий формат має кілька мінусів:
- Він призначений для машинної обробки (не кожний користувач знає, що таке код 400).
- Не зрозуміло, що є причиною помилки та як її виправити.
Тому в Spring MVC є кілька властивостей, які додають корисну інформацію в таку відповідь, наприклад:
server.error.include-message=always
Тепер JSON включає властивість message, що містить опис помилки:
{
"timestamp": "2022-11-18T19:16:44.253+00:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed for object='bookDTO'. Error count: 1",
"path": "/books"
}
При цьому це (та інші) властивості може приймати спеціальне значення on-param, коли сам клієнт вирішує, яка інформація повинна повертатися в такій відповіді (через параметри запиту):
server.error.include-message=on-param
Якщо встановити властивість include-exception, то у відповіді буде ще й той клас-виключення, який останнім викинувся на сервері (якщо такий був):
server.error.include-exception=true
{
"timestamp": "2022-11-18T19:19:17.943+00:00",
"status": 400,
"error": "Bad Request",
"exception": "org.springframework.web.bind.MethodArgumentNotValidException",
"message": "Validation failed for object='bookDTO'. Error count: 1",
"path": "/books"
}
Іноді для розробника (тестувальника) важливо отримати ще й stack-trace помилки при тому, що вони не мають доступу до сервера. Тоді потрібно включити ось таку властивість:
server.error.include-stacktrace=always
І stack-trace буде повернено:
{
"timestamp": "2022-11-18T19:20:10.234+00:00",
"status": 400,
"error": "Bad Request",
"trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public void demo.BookController.saveBook(it.discovery.dto.BookDTO): [Field error in object 'bookDTO' on field 'name': rejected value [null]; codes [NotEmpty.bookDTO.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookDTO.name,name]; arguments [];",
"message": "Validation failed for object='bookDTO'. Error count: 1",
"path": "/books"
}
Але у всіх цих випадках немає причини помилки (тобто тих даних, які не пройшли валідацію). Для цього потрібно включити властивість include-binding-errors:
server.error.include-binding-errors=always
І тут вже у відповіді буде поле errors (саме errors, а не error, тому що помилок може бути декілька), де будуть перераховані поля з некоректними значеннями та текст помилки:
{
"timestamp": "2022-11-18T19:18:30.687+00:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed for object='bookDTO'. Error count: 1",
"errors": [
{
"codes": [
"NotEmpty.bookDTO.name",
"NotEmpty.name",
"NotEmpty.java.lang.String",
"NotEmpty"
],
"arguments": [
{
"codes": [
"bookDTO.name",
"name"
],
"defaultMessage": "name",
"code": "name"
}
],
"defaultMessage": "must not be empty",
"objectName": "bookDTO",
"field": "name",
"bindingFailure": false,
"code": "NotEmpty"
}
],
"path": "/books"
}
Але навіть в останньому випадку документ, що повертається, не позбавлений недоліків:
- Цей формат не стандартизований і є внутрішнім форматом Spring Framework (як і майже все, що є в цьому фрейморку). Логування помилок є базовим правилом у сучасних проєктах. Як бути, якщо ми отримуємо повідомлення про помилки у різних форматах?
- Де дізнатися більше про правила валідації?
Problem Detail
Ситуація, коли є загальна проблема, але немає загального рішення, не характерна для ІТ у XXI столітті. Але тільки в 2016 році організація IETF запропонувала нову специфікацію Problem Details for HTTP APIs, в якій описувалася структура повідомлення про помилку для використання в HTTP API.
Відповідно до цієї специфікації, problem detail — це JSON документ, що містить 5 властивостей (деякі їх опціональні):
- type — посилання на сайт (сторінку), де пояснюється причина помилки (або приклади правильного використання). Або просто «about:blank»;
- title — короткий опис помилки, який може бути локалізований, але не прив’язаний до поточного запиту;
- status — HTTP статус-код;
- detail — повна інформація про помилку, яка містить деталі саме з цього запиту;
- instance — посилання, яке відноситься до поточної проблеми (за замовчуванням це URI поточного запиту).
Відразу постає питання. А навіщо ще раз дублювати статус? По-перше, це поле опціональне, по-друге, воно містить статус тільки для тих клієнтських додатків, які не підтримують Problem Detail. По-третє, воно дозволяє отримати точний статус на той випадок, якщо значення HTTP статус-коду було змінено в результаті генерації відповіді або закешовано.
Крім того, цей формат є розширеним. Ви можете додавати нові поля до цього JSON-документа, так звані extension members. Наприклад, для нашого випадку це може бути статус рахунку, баланс або якась інша інформація.
Тепер розробники Spring не могли проігнорувати цей стандарт і у версії 6.0 (яка вийшла в листопаді 2022 року) додали підтримку Problem Detail. Насамперед це новий MIME-тип, який повертатиметься:
public static final String APPLICATION_PROBLEM_JSON_VALUE = "application/problem+json";
Далі йде новий інтерфейс, який буде базовим для всіх винятків, що базуються на Problem Detail:
public interface ErrorResponse {
HttpStatusCode getStatusCode();
ProblemDetail getBody();
І якщо зараз викинути знайомий ResponseStatusException, формат відповіді вже буде відрізнятися від того, що був у версії 5.3:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"instance": "/books/1"
}
Як бути зі зворотною сумісністю? Можливо, додадуть нову опцію для тих додатків, які мігрувати з 5.x, але не захочуть використовувати нову функціональність.
Клас ProblemDetail містить усі властивості, описані у специфікації:
public class ProblemDetail {
private static final URI BLANK_TYPE = URI.create("about:blank");
private URI type = BLANK_TYPE;
@Nullable
private String title;
private int status;
@Nullable
private String detail;
@Nullable
private URI instance;
@Nullable
private Map<String, Object> properties;
Його використовувати досить просто:
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Account is locked");
detail.setType(URI.create("http://example.com/accounts"));
detail.setInstance(URI.create("/accounts/123"));
detail.setProperty("accountNumber", "ACC122");
throw new ErrorResponseException(HttpStatus.FORBIDDEN, detail, null);
Тут null — це відсутнє виключення. В результаті на виході отримає наступну відповідь:
{
"type": "http://example.com/accounts",
"title": "Forbidden",
"status": 403,
"detail": "Account is locked",
"instance": "/accounts/123",
"accountNumber": "ACC122"
}
Якщо вам не підходить виключення або ви обробляєте його у вашому exception handler, то знайомий клас ResponseEntity також адаптований до нових правил гри:
return ResponseEntity.of(detail).build();
Тут може виникнути цікава колізія, якщо ви ненароком повернете не той статус-код, який вказали в response body:
return ResponseEntity.badRequest().body(detail);
На цей випадок навіть є відповідний ticket, який був реалізований дуже просто — шляхом логування цього випадка з рівнем «warning».
Формат vnd.error
Буде корисним згадати про формат vnd.error, який вигадав шотландський програміст Бен Лонгден у 2014 році і якому відповідає MIME-тип application/vnd.error+json:
{
"message": "User with such login already exists",
"path": "/login",
"logref": 10,
"_links": {
"about": {
"href": "http://example.com/users/1"
},
"describes": {
"href": "http:// example.com /describes"
},
"help": {
"href": "http:// example.com /help"
}
}
}
Цей JSON-документ може містити такі поля:
- message — повідомлення для кінцевого користувача;
- logref — ідентифікатор запиту (помилки) для логування;
- path (опціонально) — інформація про елемент ресурсу, що містить помилку;
- links — список посилань на ресурси (документи), що описують знайдену помилку.
І якщо ви використовуєте Spring HATEOAS, то він спочатку включав підтримку цього формату (через відсутність кращого), але вже у версії 1.1 оголосив його deprecated:
* @deprecated since 1.1, use {@link org.springframework.hateoas.mediatype.problem.Problem} to form vendor neutral error
* messages.
*/
@Deprecated
public class VndErrors extends CollectionModel<VndErrors.VndError> {
І перейшов у 2019 році на той же стандарт RFC 7807:
public class Problem {
private static Problem EMPTY = new Problem();
private final @Nullable URI type;
private final @Nullable String title;
private final @Nullable HttpStatus status;
private final @Nullable String detail;
private final @Nullable URI instance;
Висновки
Спочатку в специфікації HTTP не було і натяку на те, як повертати повідомлення (та деталі) помилки в повідомленнях HTTP. Це призвело до ситуації, коли кожен проєкт змушений був створювати свій власний формат, що ускладнювало інтеграцію для кінцевих споживачів. Тому прийняття специфікації Problem Detail (RFC 7807) у 2016 році внесло лад у це питання і цей формат рекомендується для використання у нових проєктах.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів