HTTP-ответы и ошибки
JSON-операции Go-сервисов Ensi используют общий конверт ответа и хелперы из app/common/http (обычный alias — commonhttp). Статус, полезная нагрузка и ошибки должны соответствовать конкретной операции в resources/openapi/. Для файловых, бинарных и HTML-ответов используйте формат, заданный контрактом операции.
Структура конверта соответствует API Design Guide: data, опциональные errors и meta. Например, ошибка валидации выглядит так:
{
"data": null,
"errors": [
{
"code": "ValidationError",
"message": "The login field is required",
"meta": { "field": "login", "rule": "required" }
}
]
}
Handler валидирует запрос, вызывает action и формирует ответ. Action получает context.Context, возвращает результат или error; HTTP-статус и конверт выбирает handler.
Успешные ответы
Data — один объект
По умолчанию возвращает HTTP 200 с телом { "data": ... }. Первый необязательный аргумент задаёт другой статус:
return commonhttp.Data(ctx, response)
return commonhttp.Data(ctx, created, http.StatusCreated)
В data передают API DTO, подготовленный mapper-ом или методом From. ORM-модель может содержать внутренние поля и другие представления дат; напрямую возвращать её из handler не следует.
Page — список с пагинацией
Возвращает { "data": [...], "meta": { "pagination": ... } }:
result, err := actions.NewSearchOffersAction().Execute(ctx.Context(), req.Params())
if err != nil {
return commonhttp.HandleError(ctx, err)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
Для локального SQL-поиска action обычно возвращает *commondto.SearchResult[T] с полями Data и Pagination. Сам Page принимает произвольную структуру пагинации, включая ответ апстрима; при nil в JSON будет meta.pagination: null. Допустимость null определяет OpenAPI операции.
Empty — успех без полезной нагрузки
Возвращает HTTP 200 и { "data": null }:
return commonhttp.Empty(ctx)
Подходит для delete и команд, у которых такой ответ описан в OpenAPI. Ошибка отсутствующей записи и поведение повторного удаления определяются контрактом конкретной операции. При портировании сохраняйте согласованное поведение исходного сервиса.
DataWithMeta — data плюс произвольный meta
Используйте, когда ответ содержит дополнительные метаданные:
return commonhttp.DataWithMeta(ctx, payload, map[string]any{
"extra": true,
})
Page вызывает этот хелпер с ключом pagination. Оба хелпера поддерживают необязательный HTTP-статус, как Data.
MapToData — маппинг в сгенерированный DTO и ответ
Если response DTO реализует From(src any) error, можно выполнить преобразование и вернуть Data одним вызовом:
return commonhttp.MapToData(ctx, actionResult, &dto.AuthData{})
Ошибка From проходит через Error с HTTP 500. Успешный generic-маппинг сам по себе не доказывает правильность полей, дат, enum и nullable-значений: проверьте их в компонентном тесте. Для преобразований типов и сложной формы ответа используйте явный mapper.
Ошибка валидации
commonhttp.Validate вызывает ValidateRequest для FormRequest. При успехе возвращает nil, при отказе — готовый HTTP-ответ:
var req dto.LoginRequest
if failResponse := commonhttp.Validate(ctx, &req); failResponse != nil {
return failResponse
}
Для нарушенных правил возвращаются HTTP 400, errors[].code = ValidationError и meta.field / meta.rule. Если сам ValidateRequest вернул ошибку, хелпер вызывает Error(ctx, "BadRequest", err, 400); формат такой ошибки отличается от массива нарушенных правил.
Ошибки: два режима
Errorпереводит типизированные ошибки приложения и выбранный handler-ом статус в общий конверт.ProxyErrorсохраняет разрешённый статус апстрима и переносит егоerrorsв собственный конверт.
Для локальных ORM-операций есть HandleError: он дополнительно распознаёт отсутствие записи.
Error
Вызов: commonhttp.Error(ctx, code, err, httpCodes...). По умолчанию HTTP-статус — 500; первый элемент httpCodes задаёт другой статус. Типизированные ошибки из app/errors (alias apperrors) имеют приоритет над переданными статусом и кодом:
| Тип ошибки из action | HTTP | code в JSON |
|---|---|---|
*apperrors.ValidateError | 400 | ValidateException |
*apperrors.InvalidArgumentError | 400 | InvalidArgument |
*apperrors.AccessError | 403 | Forbidden |
*apperrors.ServiceUnavailableError | 503 | ServiceUnavailable |
*apperrors.ExposedError | переданный статус или 500 | переданный code; сообщение раскрывается явно |
| остальные ошибки | переданный статус или 500 | переданный code, с учётом правил раскрытия ниже |
ValidateException — код доменного отказа ValidateError; ошибки FormRequest используют ValidationError.
Примеры:
return commonhttp.Error(ctx, "BadRequest", fmt.Errorf("invalid query"), http.StatusBadRequest)
return commonhttp.Error(ctx, "Unauthorized", err, http.StatusUnauthorized)
return commonhttp.Error(ctx, "NotFound", err, http.StatusNotFound)
return commonhttp.Error(ctx, "ServerError", err)
Для маршрутов с числовым параметром id middleware RouteID отклоняет невалидный идентификатор с HTTP 404 до вызова handler. Это отличается от ошибки валидации тела или query.
HandleError — ORM not found и ошибки приложения
commonhttp.HandleError(ctx, err) проверяет OrmRecordNotFound и возвращает HTTP 404 с кодом NotFound. Остальные ошибки передаются в Error(ctx, "ServerError", err) с сохранением правил для типизированных ошибок:
model, err := action.Execute(ctx.Context(), id)
if err != nil {
return commonhttp.HandleError(ctx, err)
}
return commonhttp.Data(ctx, mappers.ResourceFromModel(*model))
Имя и сигнатура mapper-а зависят от ресурса. Сам Error автоматически не распознаёт ORM not found: используйте HandleError или задайте 404 явно.
Сообщения в production и debug
При app.debug = false обычные ошибки с HTTP 5xx скрываются: клиент получает ServerError / Internal server error. Исходная ошибка логируется через facades.Log().WithContext(...). Это правило применяется к ошибкам БД, файлов, сети и другим внутренним сбоям.
Исключения — ServiceUnavailableError и ExposedError: их сообщения возвращаются и при выключенном debug. Создавайте их только с текстом, предназначенным для потребителя API. Для InvalidArgumentError в production раскрывается Message, а вложенная причина Err скрывается. Обычные локальные 4xx раскрывают текст ошибки, поэтому передаваемое сообщение должно соответствовать публичному контракту.
При включённом debug обычная ошибка содержит err.Error(). HTTP-ошибка клиента *apierrors.Error из goravel-ensi-http-client представляется как HttpClientError с meta.path, method, status, message и, если задано, service. При выключенном debug её детали скрываются. Для такого сбоя собранного внутреннего запроса обычно используют HTTP 500.
ProxyError
Вызов: commonhttp.ProxyError(ctx, err, httpCodes...).
- Если ошибка содержит
*apierrors.Errorи её статус перечислен вhttpCodes, сохраняется HTTP-статус апстрима. - Из тела апстрима копируется массив
errorsв новый конверт сdata: null. Произвольные поля тела,metaконверта и заголовки апстрима автоматически не проксируются. - Если
errorsразобрать нельзя, хелпер собирает один элемент с кодом по статусу и доступным сообщением ошибки либо стандартным текстом. - Если тип или статус не подходит, вызывается
Error(ctx, "ServerError", err). Пустой список статусов также ведёт в эту ветку.
Разрешайте только статусы из своей OpenAPI-операции, для которых смысл ошибки и имена полей совпадают с апстримом:
result, err := action.Execute(ctx.Context(), params)
if err != nil {
return commonhttp.ProxyError(ctx, err, http.StatusBadRequest)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
resource, err := action.Execute(ctx.Context(), id)
if err != nil {
return commonhttp.ProxyError(ctx, err, http.StatusNotFound)
}
return commonhttp.Data(ctx, resource)
ProxyError подходит для прямого проксирования. Если сервис переименовал поля, собрал запрос из нескольких источников или использовал серверные значения, ошибка апстрима может означать внутренний сбой. В этом случае используйте Error либо переведите отказ в осмысленную доменную ошибку.
Возможность хелпера проксировать 422 или возвращать 503 не вводит эти статусы автоматически в каждую операцию. Общий API Guide содержит более узкий список: расхождения существующих контрактов фиксируйте и согласовывайте с владельцем API. При портировании изменение статусов требует отдельного решения.
Что возвращать из action
Ошибки приложения импортируют из goravel/app/errors:
import apperrors "goravel/app/errors"
Примеры отказов:
return apperrors.NewInvalidArgumentError("unknown offer")
return apperrors.NewInvalidArgumentErrorWrap("invalid sort", err)
return apperrors.NewValidateError("status transition is not allowed")
return apperrors.NewAccessError("Forbidden")
return err
apperrors.IsNotFound(err) распознаёт HTTP 404 апстрима. Отсутствие ORM-записи проверяют отдельно. Возвращать пустой список вместо upstream 404 следует только там, где это предусмотрено бизнес-сценарием.
Типичный скелет хэндлера
Пример локального SQL search:
func (c *OffersController) Search(ctx http.Context) http.Response {
var req commondto.SearchRequest
if failResponse := commonhttp.Validate(ctx, &req); failResponse != nil {
return failResponse
}
result, err := actions.NewSearchOffersAction().Execute(ctx.Context(), req.Params())
if err != nil {
return commonhttp.HandleError(ctx, err)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
}
Правила описывают app/common/http/responses.go и app/errors/errors.go