Перейти к основному содержимому

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) имеют приоритет над переданными статусом и кодом:

Тип ошибки из actionHTTPcode в JSON
*apperrors.ValidateError400ValidateException
*apperrors.InvalidArgumentError400InvalidArgument
*apperrors.AccessError403Forbidden
*apperrors.ServiceUnavailableError503ServiceUnavailable
*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...).

  1. Если ошибка содержит *apierrors.Error и её статус перечислен в httpCodes, сохраняется HTTP-статус апстрима.
  2. Из тела апстрима копируется массив errors в новый конверт с data: null. Произвольные поля тела, meta конверта и заголовки апстрима автоматически не проксируются.
  3. Если errors разобрать нельзя, хелпер собирает один элемент с кодом по статусу и доступным сообщением ошибки либо стандартным текстом.
  4. Если тип или статус не подходит, вызывается 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