приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
|
||||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||
const (
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeBadRequest = "bad_request"
|
||||
CodeTooLarge = "too_large"
|
||||
CodeNotReady = "not_ready"
|
||||
CodeTooManyRequests = "too_many_requests"
|
||||
CodeInternal = "internal"
|
||||
)
|
||||
|
||||
// ErrorBody — единая форма тела отказа на всех адресах приложения.
|
||||
//
|
||||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
||||
// журнале владельца сервиса.
|
||||
//
|
||||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||
// числом, а не пересказать его словами.
|
||||
type ErrorBody struct {
|
||||
Code string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
||||
// второе не говорило ему ничего.
|
||||
//
|
||||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
|
||||
func mapDomainError(err error) (int, ErrorBody) {
|
||||
switch {
|
||||
case errors.Is(err, contract.ErrBadRequest):
|
||||
// Причина у всех негодных вводов одна, а сказать человеку надо разное:
|
||||
// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
|
||||
// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
|
||||
// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
|
||||
// а не библиотека.
|
||||
message := "Запрос составлен неверно"
|
||||
var owned *messagedError
|
||||
if errors.As(err, &owned) {
|
||||
message = owned.message
|
||||
}
|
||||
return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}
|
||||
|
||||
case errors.Is(err, contract.ErrRecordUnreadable):
|
||||
return http.StatusBadRequest, ErrorBody{
|
||||
Code: CodeBadRequest,
|
||||
Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrRecordTooLarge):
|
||||
limit := entity.MaxRecordSize
|
||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||
Code: CodeTooLarge,
|
||||
Message: "Запись больше допустимого размера",
|
||||
Limit: &limit,
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrTextNotReady):
|
||||
return http.StatusConflict, ErrorBody{
|
||||
Code: CodeNotReady,
|
||||
Message: "Текст этого вида для записи ещё не готов",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrNotFound):
|
||||
message := "Адрес не найден"
|
||||
var owned *messagedError
|
||||
if errors.As(err, &owned) {
|
||||
message = owned.message
|
||||
}
|
||||
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
|
||||
|
||||
case errors.Is(err, contract.ErrUnauthorized):
|
||||
return http.StatusUnauthorized, ErrorBody{
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Требуется вход",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrOwnerRequired):
|
||||
return http.StatusForbidden, ErrorBody{
|
||||
Code: CodeForbidden,
|
||||
Message: "У вашей сессии нет учётной записи пользователя",
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||
// ответов иначе перебирается список заведённых записей.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return http.StatusNotFound, ErrorBody{
|
||||
Code: CodeNotFound,
|
||||
Message: "Запись не найдена",
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError, ErrorBody{
|
||||
Code: CodeInternal,
|
||||
Message: "Внутренняя ошибка сервиса",
|
||||
}
|
||||
}
|
||||
|
||||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||
// уходит наружу с адресов приложения.
|
||||
func fail(e *core.RequestEvent, err error) error {
|
||||
status, body := mapDomainError(err)
|
||||
return e.JSON(status, body)
|
||||
}
|
||||
|
||||
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
|
||||
//
|
||||
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
|
||||
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
|
||||
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
|
||||
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
|
||||
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
|
||||
//
|
||||
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
|
||||
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
|
||||
// «внутренняя ошибка» вместо предела числом.
|
||||
//
|
||||
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
|
||||
// него, включая предел тела и ограничитель частоты.
|
||||
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberOneErrorForm",
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
err := e.Next()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Обработчик, ответивший через fail, ошибки не возвращает — его
|
||||
// форма уже ушла в ответ, и сюда доходит только чужая.
|
||||
var apiErr *router.ApiError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
status, translated := translateAPIError(apiErr)
|
||||
return e.JSON(status, translated)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
|
||||
// названы поимённо: значение вне перечня приложению разбирать нечем.
|
||||
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||
switch apiErr.Status {
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
limit := entity.MaxRecordSize
|
||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||
Code: CodeTooLarge,
|
||||
Message: "Запись больше допустимого размера",
|
||||
Limit: &limit,
|
||||
}
|
||||
case http.StatusTooManyRequests:
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
case http.StatusNotFound:
|
||||
return http.StatusNotFound, ErrorBody{
|
||||
Code: CodeNotFound,
|
||||
Message: "Адрес не найден",
|
||||
}
|
||||
case http.StatusUnauthorized:
|
||||
return mapDomainError(contract.ErrUnauthorized)
|
||||
}
|
||||
|
||||
return apiErr.Status, ErrorBody{
|
||||
Code: CodeInternal,
|
||||
Message: "Внутренняя ошибка сервиса",
|
||||
}
|
||||
}
|
||||
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
|
||||
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
|
||||
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
|
||||
// поимённо: без имени пускается всякая учётная запись хранилища, включая
|
||||
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
|
||||
// записи он стать не может.
|
||||
//
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberRequireUser",
|
||||
// Сразу после слоя, который читает предъявленный токен: раньше него
|
||||
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return fail(e, contract.ErrUnauthorized)
|
||||
}
|
||||
|
||||
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
||||
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
||||
// предъявитель себя предъявил.
|
||||
if e.Auth.Collection().Name != usersCollection {
|
||||
return fail(e, contract.ErrOwnerRequired)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user