хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+250
-49
@@ -1,50 +1,119 @@
|
||||
# archive Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change app-json-contract. Update Purpose after archive.
|
||||
|
||||
Что приложение спрашивает у сервиса и что получает в ответ: собственное адресное
|
||||
пространство под корнем `/app/`, единая форма отказа, объявленные пределы,
|
||||
страница своих записей, карточка записи, текст названного вида и файл записи
|
||||
названной копии.
|
||||
|
||||
Приём записи нормирует `intake`, узнавание пришедшего — `access`, раздачу самого
|
||||
приложения — `webapp`, хранение записи и её файлов — `storage`.
|
||||
|
||||
Сознательно не описаны: правка записи и её удаление — их приносят отдельные
|
||||
задачи, и до них у приложения нет ни одного адреса, меняющего чужую строку.
|
||||
## Requirements
|
||||
|
||||
### Requirement: Адреса приложения живут своим пространством
|
||||
|
||||
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
|
||||
занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно
|
||||
вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он
|
||||
литерал библиотеки, а не настройка.
|
||||
заводить второго адресного пространства рядом. Пространство `/api/`, прежде
|
||||
принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают
|
||||
существовать: сервис их не занимает и отвечает на них тем же, чем отвечает
|
||||
всякий неизвестный путь, — норму держит capability `webapp`.
|
||||
|
||||
Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся:
|
||||
обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они
|
||||
молча — тем же адресом начнёт отвечать не тот обработчик.
|
||||
Корень приложения остаётся прежним, и это решение подтверждается, а не
|
||||
принимается заново: формы запросов и ответов приложения смена хранилища не
|
||||
трогает ни одним полем.
|
||||
|
||||
Цена переезда называется здесь же. Правило неизвестного пути, по которому
|
||||
приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не
|
||||
один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель
|
||||
частоты хранилища настроен на чужой корень и наших адресов больше не покрывает,
|
||||
поэтому сервис MUST заводить своё правило под корень приложения.
|
||||
Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого
|
||||
обновления, вправе занять новое имя рядом с нашим, больше нет.
|
||||
|
||||
Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность
|
||||
и выключен умолчанием, поэтому включение нашего правила вводит в действие и его
|
||||
собственные — на входе, на заведении записей и на его адресах. Принимается
|
||||
сознательно: без включения наше правило не значит ничего.
|
||||
Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по
|
||||
адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде
|
||||
включение нашего правила вводило в действие и чужие правила на чужих адресах;
|
||||
платить за это больше нечем — чужих адресов нет.
|
||||
|
||||
Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки
|
||||
человека живут под корнем приложения.
|
||||
**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только
|
||||
тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во
|
||||
всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок
|
||||
MUST не влиять на ключ бюджета ничем.
|
||||
|
||||
**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из
|
||||
перечня доверенных, и брать первый недоверенный.** Читаются при этом **все**
|
||||
строки заголовка, а не первая: цепочка законно приходит несколькими строками.
|
||||
Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок
|
||||
дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и
|
||||
ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет
|
||||
обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST
|
||||
падать обратно на адрес пира.
|
||||
|
||||
Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и
|
||||
пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь
|
||||
сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить
|
||||
заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||
ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
|
||||
запрос, и меняет он его на каждом запросе.
|
||||
|
||||
**Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
|
||||
Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
|
||||
вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той
|
||||
же строкой, которой обходится, — норму держит capability `access`. Ограничителю
|
||||
нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не
|
||||
виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на
|
||||
оба ломает либо барьер, либо бюджет.
|
||||
|
||||
#### Scenario: Адрес приложения отвечает под своим корнем
|
||||
|
||||
- **GIVEN** человек вошёл и предъявил сессию
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** он спрашивает список своих записей под корнем приложения
|
||||
- **THEN** ответ приходит от сервиса, а не от хранилища
|
||||
- **THEN** ответ приходит от сервиса
|
||||
|
||||
#### Scenario: Прежние адреса приложения не отвечают
|
||||
#### Scenario: Пространства хранилища не существует
|
||||
|
||||
- **GIVEN** заведена запись
|
||||
- **WHEN** её спрашивают прежними адресами в чужом пространстве
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** запрос приходит на путь под прежним корнем хранилища
|
||||
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
|
||||
|
||||
#### Scenario: Адреса панели не существует
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
|
||||
записанный его кодом
|
||||
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
|
||||
|
||||
#### Scenario: Ограничитель частоты покрывает адреса приложения
|
||||
|
||||
- **WHEN** сервис поднялся
|
||||
- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем
|
||||
приложения
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения
|
||||
- **THEN** лишние получают отказ ограничителя
|
||||
|
||||
#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты
|
||||
|
||||
- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один
|
||||
- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения
|
||||
- **THEN** бюджет каждого считается отдельно
|
||||
- **AND** исчерпание бюджета одним не отказывает другому
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет
|
||||
|
||||
- **GIVEN** запросы приходят с адреса вне перечня доверенных
|
||||
- **WHEN** они несут заголовок пересылки с разными значениями адреса
|
||||
- **THEN** бюджет у них общий и считается по адресу пира
|
||||
|
||||
#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт
|
||||
|
||||
- **GIVEN** запросы приходят с доверенного адреса
|
||||
- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение
|
||||
спрашивающего, а справа — адрес, приписанный прокси
|
||||
- **THEN** бюджет считается по правому значению
|
||||
- **AND** запросы чаще бюджета получают отказ ограничителя
|
||||
|
||||
#### Scenario: Цепочка читается всеми строками заголовка
|
||||
|
||||
- **GIVEN** запросы приходят с доверенного адреса
|
||||
- **WHEN** цепочка пересылки приходит несколькими строками заголовка
|
||||
- **THEN** ключ бюджета берётся из последней строки, а не из первой
|
||||
|
||||
### Requirement: Отказ называет причину, а не место
|
||||
|
||||
@@ -54,21 +123,24 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
|
||||
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
|
||||
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
|
||||
разнице кодов перебирается список заведённых записей;
|
||||
- узнанный предъявитель без учётной записи пользователя — `403`;
|
||||
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
|
||||
отвечать чужая и ничья запись;
|
||||
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
|
||||
негодный размер страницы;
|
||||
негодный размер страницы, негодный диапазон в запросе файла;
|
||||
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
|
||||
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
|
||||
записи ещё нет;
|
||||
- отказ хранилища и всякая неназванная причина — `500`.
|
||||
- состояние, в котором действие недоступно, — `409`: текста или копии файла
|
||||
запрошенного вида у записи ещё нет;
|
||||
- отказ базы и всякая неназванная причина — `500`.
|
||||
|
||||
Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла
|
||||
вместе со своим единственным случаем: им был владелец панели, предъявивший
|
||||
собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а
|
||||
узнавание по заголовку учётную запись заводит само, и предъявителя без неё не
|
||||
бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся
|
||||
в коде мёртвой.
|
||||
|
||||
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
|
||||
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
|
||||
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
|
||||
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
|
||||
«моя запись пропала», а второе не говорит ему ничего.
|
||||
адреса, и у него MUST быть определённая ветвь по умолчанию.
|
||||
|
||||
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
|
||||
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
|
||||
@@ -82,8 +154,8 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
|
||||
выбранные кодом они стали бы контрактом молча:
|
||||
|
||||
- поля тела: `error_code` и `message`;
|
||||
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
|
||||
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
|
||||
- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`,
|
||||
`too_large`, `too_many_requests`, `not_ready`, `internal`.
|
||||
|
||||
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
|
||||
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
|
||||
@@ -97,13 +169,13 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
|
||||
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
|
||||
журнале аварию там, где её нет.
|
||||
|
||||
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
|
||||
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
|
||||
Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали
|
||||
устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка
|
||||
остаётся в журнале владельца сервиса.
|
||||
|
||||
#### Scenario: Сбой хранилища виден как сбой
|
||||
#### Scenario: Сбой базы виден как сбой
|
||||
|
||||
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
|
||||
- **GIVEN** база отвечает отказом драйвера на чтение записи
|
||||
- **WHEN** владелец спрашивает свою запись
|
||||
- **THEN** ответ имеет код `500`
|
||||
- **AND** тела записи в ответе нет
|
||||
@@ -115,16 +187,10 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
|
||||
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
|
||||
- **AND** причина отказа в тело ответа не попадает
|
||||
|
||||
#### Scenario: Отказ по пустому владельцу
|
||||
|
||||
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
|
||||
- **WHEN** он шлёт запись приёмом
|
||||
- **THEN** ответ имеет код `403`
|
||||
|
||||
#### Scenario: Форма тела одна на всех ветвях отказа
|
||||
|
||||
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
|
||||
отсутствию учётной записи и по сбою хранилища
|
||||
недоступному состоянию и по сбою базы
|
||||
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
|
||||
полями
|
||||
- **AND** код отказа принадлежит закрытому перечню
|
||||
@@ -468,3 +534,138 @@ MUST браться колонками самой записи, а не стро
|
||||
называет вида вовсе
|
||||
- **THEN** ответ имеет код `400`
|
||||
|
||||
### Requirement: Файл записи отдаётся адресом приложения
|
||||
|
||||
Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET
|
||||
/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную
|
||||
спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что
|
||||
успел записать конвейер, а не состояния записи.
|
||||
|
||||
**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со
|
||||
значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным
|
||||
контрактом молча.
|
||||
|
||||
**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:**
|
||||
|
||||
- `original` — файл, принятый от отправителя;
|
||||
- `normalized` — копия, приведённая к рабочему формату.
|
||||
|
||||
Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же
|
||||
кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как
|
||||
пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек
|
||||
увидел бы «не найдено» на своей записи, загруженной минуту назад.
|
||||
|
||||
Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному
|
||||
вводу — но только у **своей** записи.
|
||||
|
||||
Порядок проверок MUST быть один: владение записью судится **до** разбора значения
|
||||
копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей
|
||||
записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и
|
||||
телом. Неотличимость чужой записи от несуществующей главнее формы ответа на
|
||||
негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на
|
||||
чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой
|
||||
разнице перебирался бы список заведённых записей одним негодным параметром.
|
||||
|
||||
Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям:
|
||||
запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает
|
||||
её запросом диапазона, а не повторной загрузкой целиком.
|
||||
|
||||
**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же
|
||||
формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом
|
||||
библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково:
|
||||
неудовлетворимый (начало за концом файла) и множественный (в запросе назван
|
||||
больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких
|
||||
частей — это отдельный тип содержимого со своими границами, а просит его один
|
||||
только самодельный запрос, потому что проигрыватель в браузере шлёт один
|
||||
диапазон.
|
||||
|
||||
Причина у требования общая с прочими отказами, рождающимися не в обработчике:
|
||||
форма тела на адресах приложения одна, и код отказа принадлежит закрытому
|
||||
перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и
|
||||
`message`, и приложение разбирает его отдельной веткой — единственной такой на
|
||||
все адреса.
|
||||
|
||||
Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же,
|
||||
чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу
|
||||
даёт узнавание, а не выданное значение, нормирует capability `storage`.
|
||||
|
||||
Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при
|
||||
сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи.
|
||||
|
||||
**Перечня доступных копий карточка записи не объявляет** — до задачи об экране
|
||||
прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий
|
||||
узнаёт отказом состояния на самом обращении за файлом.
|
||||
|
||||
Довод, которым перечень доступных видов текста объявляется карточкой всегда, к
|
||||
копиям файла не относится, и это разные случаи. Видов текста несколько, шаг
|
||||
завершения пишет их несколькими операциями, поэтому состояние «сплошной текст
|
||||
есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение
|
||||
обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и
|
||||
каждая выводится из рубежа записи, который карточка несёт и так: принятая копия
|
||||
есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение.
|
||||
Второе поле повторяло бы рубеж и разошлось бы с ним молча.
|
||||
|
||||
Перечень появится тогда, когда у него появится потребитель: экран прослушивания
|
||||
приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять
|
||||
контракт, которого никто не разбирает.
|
||||
|
||||
#### Scenario: Владелец забирает принятую копию
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** владелец просит её файл копией `original`
|
||||
- **THEN** ответ несёт содержимое принятого файла и его длину
|
||||
|
||||
#### Scenario: Приведённой копии ещё нет
|
||||
|
||||
- **GIVEN** запись принята и не дошла до приведения
|
||||
- **WHEN** владелец просит её файл копией `normalized`
|
||||
- **THEN** ответ имеет код `409`
|
||||
- **AND** он отличается от ответа на неизвестный идентификатор
|
||||
|
||||
#### Scenario: Копия неизвестна или не названа
|
||||
|
||||
- **WHEN** владелец просит файл копией, которой сервис не знает, либо не
|
||||
называет копии вовсе
|
||||
- **THEN** ответ имеет код `400`
|
||||
|
||||
#### Scenario: Чужой файл неотличим от неизвестной записи
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её файл просит другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** другой узнанный просит её файл копией, которой сервис не знает
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи
|
||||
|
||||
#### Scenario: Перечня копий в карточке нет
|
||||
|
||||
- **GIVEN** запись принята и приведена к рабочему формату
|
||||
- **WHEN** владелец спрашивает её карточку
|
||||
- **THEN** поля с перечнем доступных копий файла в ответе нет
|
||||
|
||||
#### Scenario: Проигрыватель просит кусок записи
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** владелец просит её файл с указанием диапазона
|
||||
- **THEN** ответ несёт запрошенный кусок, а не файл целиком
|
||||
|
||||
#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом
|
||||
|
||||
- **GIVEN** запись принята, и её файл короче запрошенного начала
|
||||
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range:
|
||||
bytes=99999999-`
|
||||
- **THEN** ответ имеет код `400`, а не `416`
|
||||
- **AND** тело несёт поля `error_code` и `message`
|
||||
|
||||
#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком,
|
||||
называющим два диапазона
|
||||
- **THEN** ответ имеет код `400`, а не `416`
|
||||
- **AND** тело несёт поля `error_code` и `message`
|
||||
- **AND** ответа из нескольких частей сервис не отдаёт
|
||||
|
||||
Reference in New Issue
Block a user