HTTP-ответы и ошибки
Все публичные эндпоинты Go-сервисов Ensi отдают JSON в одном формате. Формировать его вручную через ctx.Response().Json(...) в хэндлерах не нужно: для успеха и ошибок есть хелперы в пакете app/common/http (в коде обычно импортируют как commonhttp).
Контракт ответа согласован с API Design Guide: поля data, опционально errors и meta.
{
"data": { },
"errors": [
{
"code": "ValidationError",
"message": "...",
"meta": { "field": "login", "rule": "required" }
}
],
"meta": {
"pagination": { }
}
}
Хэндлер после валидации вызывает action и сразу возвращает один из хелперов ниже. Action не знает про HTTP-статус и не собирает envelope — он возвращает данные или обычный Go error.
Успешные ответы
Data — один объект
Возвращает HTTP 200 (или другой код, если передан явно) с телом { "data": ... }.
return commonhttp.Data(ctx, brand)
return commonhttp.Data(ctx, created, 201)
Используйте для get-one, create/update, когда в спеке в data лежит одна сущность или DTO.
Page — список с пагинацией
Для search и других списков: { "data": [...], "meta": { "pagination": ... } }.
result, err := actions.NewSearchOffersAction().Execute(ctx.Context(), req.Params())
if err != nil {
return commonhttp.Error(ctx, "ServerError", err)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
pagination может быть nil (в JSON будет null) или структурой/map из search-слоя / ответа апстрима. Action обычно возвращает *commondto.SearchResult[T] с полями Data и Pagination.
Empty — успех без полезной нагрузки
HTTP 200 и { "data": null } (пустое тело конверта без errors). Подходит для операций, у которых в OpenAPI пустой успешный ответ: часть delete/mass-операций, триггеры миграции и т.п.
return commonhttp.Empty(ctx)
DataWithMeta — data плюс произвольный meta
Когда нужна meta не только с пагинацией (флаги, дополнительные блоки). Page внутри как раз вызывает DataWithMeta с ключом pagination.
return commonhttp.DataWithMeta(ctx, payload, map[string]any{
"extra": true,
})
MapToData — маппинг в сгенерированный DTO и ответ
Если у response-DTO есть метод From(src any) error (часто генерируется из OpenAPI), можно сразу смапить результат action в тип ответа и отдать Data:
return commonhttp.MapToData(ctx, actionResult, &dto.AuthData{})
При ошибке From хелпер вернёт Error с кодом серверной ошибки. Если форма ответа сильно отличается от источника, обычно пишут явный mapper и вызывают Data, а не полагаются на MapToData.
Ошибка валидации
Фунция commonhttp.Validate проверяет входящий запрос, и либо биндит данные в переданную структуру, либо возвращает ошибку валидации. Эту ошибку можно сразу вернуть как http ответ:
var req dto.LoginRequest
if failResponse := commonhttp.Validate(ctx, &req); failResponse != nil {
return failResponse
}
При ошибках валидации клиент получает 400 и массив errors с кодом ValidationError; в errors[].meta у элемента обычно есть field и rule для детализации - какое поле и по какому правилу не прошло.
Если валидация прошла, Validate возвращает nil, и хэндлер продолжает работу.
Ошибки: два режима
Action возвращает error. Хэндлер решает, как показать его клиенту:
Error— показывает клиенту указанный код ошибки и сообщение, или, если это особый тип ошибки (InvalidArgumentError,AccessError), подменяет код/сообщениеProxyError— можно отдать некоторые ошибки апстрима сразу как свой ответ, например когда search запрос без изменений проксируется, можно обратно без изменений отдать ошибку валидации
Error
commonhttp.Error(ctx, code string, err error, httpCodes ...int)
code— строковый код вerrors[].code(например"NotFound","BadRequest","ServerError"), если не сработала перезапись типизированной ошибкой;httpCodes— опционально первый элемент задаёт HTTP-статус; если не передан, по умолчанию 500;
Перезапись типизированными ошибками (приоритетнее переданного httpCodes / code):
| Тип ошибки из action | HTTP | code в JSON |
|---|---|---|
*common.InvalidArgumentError | 400 | InvalidArgument |
*common.AccessError | 403 | Forbidden |
| остальное | переданный статус или 500 если статус не передан | переданный code |
Когда использовать Error:
- невалидный path/query на стороне gateway (
BadRequest, 400); - не удалось загрузить пользователя / токен (
Unauthorized, 401); - доменная «не найдено» (
NotFound, 404); - запрос к апстриму собран и преобразован (другие имена полей, несколько сервисов) — клиенту нельзя отдавать сырой 400 апстрима, обычно это
ServerError/ 500; - любая неожиданная ошибка.
Примеры:
return commonhttp.Error(ctx, "BadRequest", fmt.Errorf("invalid id"), 400)
return commonhttp.Error(ctx, "Unauthorized", err, 401)
return commonhttp.Error(ctx, "NotFound", err, 404)
return commonhttp.Error(ctx, "ServerError", err)
Ошибки HTTP-клиента внутри Error (не ProxyError)
Если err — ошибка вызова другого сервиса Ensi (*apierrors.Error из goravel-ensi-http-client), а хэндлер вызвал Error, а не ProxyError:
- в production (
app.debug = false) клиент видит только{ "code": "ServerError", "message": "Internal server error" }— детали апстрима не утекают; - в debug клиент видит код
HttpClientErrorиmetaсservice,path,method,status,message— удобно локально разбирать сбои.
То есть «непроксируемый» апстрим по умолчанию выглядит как внутренняя ошибка сервиса.
ProxyError
commonhttp.ProxyError(ctx, err, httpCodes ...int)
Поведение:
- Если
err— upstream HTTP-ошибка и её статус есть в спискеhttpCodes, клиенту отдаётся ответ апстрима как есть. - Массив
errorsпо возможности копируется из тела ответа апстрима (стандартный Ensi-конверт). Если разобрать тело нельзя — собирается один элемент с кодом/сообщением по статусу (ValidationError,Unauthorized,Forbidden,NotFound, иначеServerError). - Во всех остальных случаях (другой тип ошибки, статус не из списка, пустой список статусов) вызывается
Error(ctx, "ServerError", err)→ по сути 500 (с учётом типизированных ошибок и debug-режима для HTTP-клиента).
Список статусов берите из своей OpenAPI-операции (клиентские 4xx, которые вы готовы пробрасывать). Обычно:
- search / create / patch:
400,422, при необходимости404; - get-one / delete:
404; - login:
400,401,403.
Не передавайте в список 500 как «проксируемый» успех сценария — пятисотки апстрима для потребителя всё равно должны выглядеть как сбой вашего сервиса, если вы не проектировали иное.
result, err := action.Execute(ctx.Context(), ...)
if err != nil {
return commonhttp.ProxyError(ctx, err, 400, 422)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
brand, err := action.Execute(ctx.Context(), id)
if err != nil {
return commonhttp.ProxyError(ctx, err, 404)
}
return commonhttp.Data(ctx, brand)
Важно: ProxyError без списка статусов для любой upstream HTTP-ошибки уйдёт в ветку ServerError (500). Всегда передавайте явный whitelist из спеки, либо используйте Error, если проксировать нечего.
ProxyError имеет смысл, когда публичный контракт близок к апстриму: те же имена полей, та же семантика 400/404. Если gateway сильно трансформирует фильтры, склеивает несколько сервисов или подменяет коды — для сбоев апстрима чаще Error, а осмысленные 4xx формируйте сами (или через типизированные ошибки action).
Типизированные InvalidArgumentError / AccessError, попавшие в ProxyError, не являются *apierrors.Error с «разрешённым» статусом, поэтому уходят в Error и корректно превращаются в 400 / 403.
Что возвращать из action
Action не выставляет HTTP-код. Он либо возвращает результат, либо ошибку:
return common.NewInvalidArgumentError("в запросе присутствует несуществующий оффер")
return common.NewInvalidArgumentErrorWrap("invalid sort", err)
return common.NewAccessError("Forbidden")
return err // пусть хэндлер решит ProxyError или Error
Для «пустого списка вместо 404» при опросе апстрима удобен хелпер common.IsNotFound(err) — проверить, что это именно HTTP 404 клиента, и вернуть пустой SearchResult без ошибки.
Проверку владельца ресурса, разбор include/sort после маппинга и прочие бизнес-отказы оформляйте типизированными ошибками в action; хэндлер остаётся тонким.
Типичный скелет хэндлера
func (c *OffersController) Search(ctx http.Context) http.Response {
var req dto.SearchOffersRequest
if failResponse := commonhttp.Validate(ctx, &req); failResponse != nil {
return failResponse
}
result, err := actions.NewSearchOffersAction().Execute(ctx.Context(), req.Params())
if err != nil {
return commonhttp.ProxyError(ctx, err, 400, 422)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
}
Вариант без проксирования (локальная БД / собранный ответ):
result, err := actions.NewSearchOffersAction().Execute(ctx.Context(), params)
if err != nil {
return commonhttp.Error(ctx, "ServerError", err)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)