- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
672 lines
53 KiB
Markdown
672 lines
53 KiB
Markdown
# archive Specification
|
||
|
||
## Purpose
|
||
|
||
Что приложение спрашивает у сервиса и что получает в ответ: собственное адресное
|
||
пространство под корнем `/app/`, единая форма отказа, объявленные пределы,
|
||
страница своих записей, карточка записи, текст названного вида и файл записи
|
||
названной копии.
|
||
|
||
Приём записи нормирует `intake`, узнавание пришедшего — `access`, раздачу самого
|
||
приложения — `webapp`, хранение записи и её файлов — `storage`.
|
||
|
||
Сознательно не описаны: правка записи и её удаление — их приносят отдельные
|
||
задачи, и до них у приложения нет ни одного адреса, меняющего чужую строку.
|
||
## Requirements
|
||
|
||
### Requirement: Адреса приложения живут своим пространством
|
||
|
||
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
|
||
заводить второго адресного пространства рядом. Пространство `/api/`, прежде
|
||
принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают
|
||
существовать: сервис их не занимает и отвечает на них тем же, чем отвечает
|
||
всякий неизвестный путь, — норму держит capability `webapp`.
|
||
|
||
Корень приложения остаётся прежним, и это решение подтверждается, а не
|
||
принимается заново: формы запросов и ответов приложения смена хранилища не
|
||
трогает ни одним полем.
|
||
|
||
Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого
|
||
обновления, вправе занять новое имя рядом с нашим, больше нет.
|
||
|
||
Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по
|
||
адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде
|
||
включение нашего правила вводило в действие и чужие правила на чужих адресах;
|
||
платить за это больше нечем — чужих адресов нет.
|
||
|
||
**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только
|
||
тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во
|
||
всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок
|
||
MUST не влиять на ключ бюджета ничем.
|
||
|
||
**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из
|
||
перечня доверенных, и брать первый недоверенный.** Читаются при этом **все**
|
||
строки заголовка, а не первая: цепочка законно приходит несколькими строками.
|
||
Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок
|
||
дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и
|
||
ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет
|
||
обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST
|
||
падать обратно на адрес пира.
|
||
|
||
Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и
|
||
пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь
|
||
сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить
|
||
заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||
ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
|
||
запрос, и меняет он его на каждом запросе.
|
||
|
||
**Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
|
||
Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
|
||
вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той
|
||
же строкой, которой обходится, — норму держит capability `access`. Ограничителю
|
||
нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не
|
||
виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на
|
||
оба ломает либо барьер, либо бюджет.
|
||
|
||
#### Scenario: Адрес приложения отвечает под своим корнем
|
||
|
||
- **GIVEN** человек узнан
|
||
- **WHEN** он спрашивает список своих записей под корнем приложения
|
||
- **THEN** ответ приходит от сервиса
|
||
|
||
#### Scenario: Пространства хранилища не существует
|
||
|
||
- **GIVEN** сервис поднялся
|
||
- **WHEN** запрос приходит на путь под прежним корнем хранилища
|
||
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
|
||
|
||
#### Scenario: Адреса панели не существует
|
||
|
||
- **GIVEN** сервис поднялся
|
||
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
|
||
записанный его кодом
|
||
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
|
||
|
||
#### Scenario: Ограничитель частоты покрывает адреса приложения
|
||
|
||
- **GIVEN** сервис поднялся
|
||
- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения
|
||
- **THEN** лишние получают отказ ограничителя
|
||
|
||
#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты
|
||
|
||
- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один
|
||
- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения
|
||
- **THEN** бюджет каждого считается отдельно
|
||
- **AND** исчерпание бюджета одним не отказывает другому
|
||
|
||
#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет
|
||
|
||
- **GIVEN** запросы приходят с адреса вне перечня доверенных
|
||
- **WHEN** они несут заголовок пересылки с разными значениями адреса
|
||
- **THEN** бюджет у них общий и считается по адресу пира
|
||
|
||
#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт
|
||
|
||
- **GIVEN** запросы приходят с доверенного адреса
|
||
- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение
|
||
спрашивающего, а справа — адрес, приписанный прокси
|
||
- **THEN** бюджет считается по правому значению
|
||
- **AND** запросы чаще бюджета получают отказ ограничителя
|
||
|
||
#### Scenario: Цепочка читается всеми строками заголовка
|
||
|
||
- **GIVEN** запросы приходят с доверенного адреса
|
||
- **WHEN** цепочка пересылки приходит несколькими строками заголовка
|
||
- **THEN** ключ бюджета берётся из последней строки, а не из первой
|
||
|
||
### Requirement: Отказ называет причину, а не место
|
||
|
||
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
|
||
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
|
||
|
||
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
|
||
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
|
||
разнице кодов перебирается список заведённых записей;
|
||
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
|
||
отвечать чужая и ничья запись;
|
||
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
|
||
негодный размер страницы, негодный диапазон в запросе файла;
|
||
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
|
||
- состояние, в котором действие недоступно, — `409`: текста или копии файла
|
||
запрошенного вида у записи ещё нет;
|
||
- отказ базы и всякая неназванная причина — `500`.
|
||
|
||
Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла
|
||
вместе со своим единственным случаем: им был владелец панели, предъявивший
|
||
собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а
|
||
узнавание по заголовку учётную запись заводит само, и предъявителя без неё не
|
||
бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся
|
||
в коде мёртвой.
|
||
|
||
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
|
||
адреса, и у него MUST быть определённая ветвь по умолчанию.
|
||
|
||
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
|
||
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
|
||
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
|
||
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
|
||
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
|
||
задача экрана переписала бы контракт, согласованный здесь один раз.
|
||
|
||
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
|
||
выбранные кодом они стали бы контрактом молча:
|
||
|
||
- поля тела: `error_code` и `message`;
|
||
- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`,
|
||
`too_large`, `too_many_requests`, `not_ready`, `internal`.
|
||
|
||
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
|
||
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
|
||
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
|
||
адресах приложения две, а самый частый отказ у человека на мобильной сети —
|
||
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
|
||
числом.
|
||
|
||
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
|
||
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
|
||
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
|
||
журнале аварию там, где её нет.
|
||
|
||
Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали
|
||
устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка
|
||
остаётся в журнале владельца сервиса.
|
||
|
||
#### Scenario: Сбой базы виден как сбой
|
||
|
||
- **GIVEN** база отвечает отказом драйвера на чтение записи
|
||
- **WHEN** владелец спрашивает свою запись
|
||
- **THEN** ответ имеет код `500`
|
||
- **AND** тела записи в ответе нет
|
||
|
||
#### Scenario: Негодная запись видна как негодная
|
||
|
||
- **GIVEN** источник метаданных не может прочитать присланную запись
|
||
- **WHEN** отправитель шлёт её приёмом
|
||
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
|
||
- **AND** причина отказа в тело ответа не попадает
|
||
|
||
#### Scenario: Форма тела одна на всех ветвях отказа
|
||
|
||
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
|
||
недоступному состоянию и по сбою базы
|
||
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
|
||
полями
|
||
- **AND** код отказа принадлежит закрытому перечню
|
||
- **AND** ни одно из них не содержит сырого текста ошибки
|
||
|
||
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
|
||
|
||
- **GIVEN** заведена запись
|
||
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
|
||
неизвестному идентификатору
|
||
- **THEN** оба ответа имеют код `401` и одно тело
|
||
|
||
#### Scenario: Запись сверх потолка размера
|
||
|
||
- **GIVEN** отправитель узнан
|
||
- **WHEN** он шлёт запись длиннее потолка размера
|
||
- **THEN** ответ имеет код `413`, а тело несёт предел числом
|
||
- **AND** ни файла, ни аудиозаписи не заводится
|
||
|
||
### Requirement: Сервис объявляет свои пределы
|
||
|
||
Сервис SHALL отдавать свои пределы отдельным адресом — `GET /app/config` — и
|
||
MUST называть в нём потолок размера одной записи, потолок размера страницы,
|
||
частоту опроса карточки, перечень известных расширений и потолок числа тем у
|
||
записи.
|
||
|
||
Предел зовётся частотой опроса **карточки**, а не готовности: адрес опроса
|
||
готовности это же изменение убирает целиком, и читатель через месяц искал бы то,
|
||
чего нет.
|
||
|
||
Имена полей ответа нормативны: `max_record_size_bytes`, `max_page_size`,
|
||
`poll_interval_ms`, `known_extensions`, `max_topics_per_record`.
|
||
|
||
**Каждый объявленный предел MUST быть тем же значением, которое сервис
|
||
применяет, а не его копией.** Правило общее, а не про один потолок размера:
|
||
приложение, знающее предел своей константой, расходится с сервером молча — до
|
||
первого отказа на записи, которую человек уже успел отправить по мобильной сети.
|
||
Ровно то же случается, когда предел объявлен сервером, но взят из второй
|
||
константы рядом с применяемой.
|
||
|
||
Отсюда источник у каждого:
|
||
|
||
- потолок размера записи — то число, которым сервис ограничивает тело запроса
|
||
приёма и отвергает запись кодом `413`;
|
||
- потолок числа тем у записи — то число, которым его ограничивает схема
|
||
хранилища; норму держит capability `storage`;
|
||
- потолок размера страницы — то число, до которого сервис усекает запрошенный
|
||
размер страницы;
|
||
- частота опроса — выводится из **доли** бюджета ограничителя частоты под корнем
|
||
приложения и MUST не задаваться своей константой. Доля, а не весь бюджет:
|
||
опрос идёт не один — в ту же секунду приложение листает список, открывает
|
||
соседнюю карточку и грузит новую запись, а бюджет один на все адреса
|
||
приложения и считается по адресу спрашивающего, а не по учётной записи.
|
||
Объявленная частота, равная всему бюджету, отдавала бы отказ на любом втором
|
||
запросе — тот самый, которого объявление обещает избежать. Иначе приложение,
|
||
честно опрашивающее карточку с объявленной частотой, упирается в собственный
|
||
ограничитель сервиса — и получает отказ, которого сервис сам же ему и обещал
|
||
избежать;
|
||
- перечень известных расширений — тот же, что сужает метку метрики, за вычетом
|
||
собственного умолчания сервиса `audio`: оно не формат, и подсказкой человеку
|
||
выходить не должно. Второй перечень рядом с первым разошёлся бы с ним молча.
|
||
|
||
**Перечень известных расширений — исключение в другом: сервис по нему не
|
||
судит.** Приём о годности записи не судит сам — расширение он берёт из
|
||
имени файла, а пригодность содержимого узнаёт у источника метаданных, — и
|
||
перечень служит приложению подсказкой для диалога выбора файла, не более.
|
||
Умолчать об этом нельзя: приложение прочитало бы перечень как «что можно
|
||
загружать» и отвергало бы запись, которую сервис принял бы и расшифровал.
|
||
|
||
Адрес MUST быть доступен тому же, кому доступны прочие адреса приложения:
|
||
пределы не тайна, но отдельного открытого адреса ради них не заводится.
|
||
|
||
#### Scenario: Потолок размера равен тому, которым сервис отвергает
|
||
|
||
- **GIVEN** человек вошёл и предъявил сессию
|
||
- **WHEN** он спрашивает пределы сервиса
|
||
- **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает
|
||
тело запроса приёма
|
||
|
||
#### Scenario: Потолок страницы равен применяемому
|
||
|
||
- **WHEN** человек спрашивает пределы сервиса, а затем просит страницу размером
|
||
сверх объявленного потолка
|
||
- **THEN** размер отданной страницы не превышает объявленного потолка
|
||
|
||
### Requirement: Страница своих записей
|
||
|
||
Сервис SHALL отдавать владельцу страницу его записей — `GET /app/audiorecords` —
|
||
новыми сверху, и MUST не показывать в ней ни одной чужой записи. Спрашивающий с
|
||
пустым именем MUST не получать ни одной записи.
|
||
|
||
Ответ MUST нести страницу, ключ следующей страницы и общее число записей. Число
|
||
записей одного человека растёт годами — сервис объявлен архивом, — и ответ без
|
||
страниц перестал бы помещаться в память телефона.
|
||
|
||
**Страница задаётся ключом, а не номером.** Приём пишет в голову той же ленты,
|
||
которую читает список, и человек, загрузивший запись и листающий свой архив, —
|
||
штатный сценарий, а не редкость. Номер страницы сдвинул бы окно на единицу:
|
||
последний элемент первой страницы пришёл бы вторым разом первым элементом второй,
|
||
а один элемент между ними не пришёл бы никогда. Отказ молчаливый — ни кода, ни
|
||
строки в журнале, — и человек видел бы архив, в котором записи нет.
|
||
|
||
Ключ MUST быть непрозрачным для спрашивающего и MUST задавать положение
|
||
**полным** ключом сортировки — парой «время заведения и идентификатор». Одного
|
||
времени мало: у записей, принятых одним запросом, оно совпадает, и порядок между
|
||
ними иначе не определён вовсе.
|
||
|
||
Ключа следующей страницы нет — страница последняя; пустая страница MUST отвечать
|
||
успехом, а не отказом: отсутствие записей не есть ошибка.
|
||
|
||
Ключ, который сервис не может прочитать — протухший, обрезанный, подделанный, —
|
||
MUST давать отказ по негодному вводу. Молчаливая отдача первой страницы вместо
|
||
этого дала бы человеку архив, листающийся по кругу, и ни строки в журнале.
|
||
|
||
**Сторона запроса нормируется наравне со стороной ответа.** У размера страницы
|
||
MUST быть умолчание и потолок; размер сверх потолка MUST усекаться до него, а не
|
||
отвергаться, а негодное значение — ноль, отрицательное, нечисловое — MUST давать
|
||
отказ по негодному вводу. Незаданный потолок был бы способом попросить весь архив
|
||
одним запросом, то есть обойти постраничность тем самым параметром, ради которого
|
||
она заведена.
|
||
|
||
Имена полей ответа и элемента нормативны: экраны строятся на них, и
|
||
переименование после того, как экран написан, стоит правки приложения.
|
||
|
||
- параметры запроса: `cursor`, `limit`, `filter`;
|
||
- страница: `items`, `next_cursor`, `total_items`;
|
||
- элемент: `id`, `title`, `original_filename`, `brief`, `topics`, `state`,
|
||
`halted`, `halt_reason`, `duration_ms`, `size_bytes`, `created_at`.
|
||
|
||
Значение `state` MUST принадлежать перечню рубежей конвейера, а `halt_reason` —
|
||
перечню причин остановки. Оба перечня объявлены одним местом, и перечислять их
|
||
порознь в потребителе нельзя: рубеж, добавленный конвейером, иначе разошёлся бы
|
||
с ответом молча.
|
||
|
||
Чтение страницы MUST не читать ни расшифровки, ни структуры реплик: обе лежат
|
||
порознь от записи ровно затем, чтобы список их не тянул. Длительность и размер
|
||
MUST браться колонками самой записи, а не строкой её файла.
|
||
|
||
Машинный текст отказа MUST в элемент страницы не попадать: он принадлежит
|
||
журналу владельца сервиса. Причина остановки — значение из закрытого перечня, и
|
||
она не он.
|
||
|
||
Значения причины остановки этим требованием впервые выходят в публичный ответ, и
|
||
это осознанно: без причины признак остановки не говорит человеку, чего ждать —
|
||
повтора, своего действия или ничего. Превращает значение в русскую фразу
|
||
**приложение**, а не сервис: сервис отдаёт значение перечня, и второй словарь
|
||
фраз на стороне сервера разошёлся бы с тем, что показывает экран.
|
||
|
||
**Отбор MUST различать три состояния, а не два:** запись в работе (`working`),
|
||
запись остановлена (`halted`), запись прошла конвейер (`done`). Незаданный отбор
|
||
значит «все». Двух значений не хватает: остановленная
|
||
запись не в работе и не завершена, и при отборе надвое она выпала бы из обеих
|
||
половин — то есть исчезла бы из списка при любом значении отбора, хотя ради неё
|
||
человек список и открывает. Предикат каждого состояния MUST выводиться из
|
||
дескриптора рубежа и признака остановки, а не перечислять рубежи строкой запроса:
|
||
рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх.
|
||
|
||
#### Scenario: Страница отдаётся новыми сверху
|
||
|
||
- **GIVEN** владелец завёл записей больше, чем помещается на страницу
|
||
- **WHEN** он спрашивает первую страницу
|
||
- **THEN** в ней лежит ровно столько записей, сколько вмещает страница
|
||
- **AND** первой стоит заведённая последней
|
||
- **AND** ответ несёт общее число его записей и ключ следующей страницы
|
||
|
||
#### Scenario: Запись, заведённая между страницами, окна не сдвигает
|
||
|
||
- **GIVEN** владелец прочитал первую страницу и взял ключ следующей
|
||
- **WHEN** он заводит новую запись и спрашивает следующую страницу этим ключом
|
||
- **THEN** ни один элемент первой страницы в ней не повторяется
|
||
- **AND** ни одна запись между страницами не пропущена
|
||
|
||
#### Scenario: Записи с одним временем заведения идут в устойчивом порядке
|
||
|
||
- **GIVEN** две записи заведены одним запросом и время заведения у них совпадает
|
||
- **WHEN** владелец читает страницу дважды
|
||
- **THEN** порядок этих записей в обоих ответах один и тот же
|
||
|
||
#### Scenario: Последняя страница
|
||
|
||
- **WHEN** владелец дочитал архив до конца
|
||
- **THEN** ответ имеет код `200`, а ключа следующей страницы в нём нет
|
||
|
||
#### Scenario: Чужих записей в странице нет
|
||
|
||
- **GIVEN** записи заведены двумя вошедшими
|
||
- **WHEN** страницу спрашивает один из них
|
||
- **THEN** в ней лежат только его записи
|
||
|
||
#### Scenario: Список не тянет расшифровку
|
||
|
||
- **GIVEN** у записи есть расшифровка
|
||
- **WHEN** владелец спрашивает страницу своих записей
|
||
- **THEN** текста расшифровки в ответе нет
|
||
- **AND** чтение страницы строку текста не трогает
|
||
|
||
#### Scenario: Размер страницы сверх потолка усекается
|
||
|
||
- **WHEN** владелец просит страницу размером больше объявленного потолка
|
||
- **THEN** ответ имеет код `200`, а размер страницы равен потолку
|
||
|
||
#### Scenario: Негодный размер страницы отвергается
|
||
|
||
- **WHEN** владелец просит страницу размером ноль либо нечисловым значением
|
||
- **THEN** ответ имеет код `400`
|
||
|
||
#### Scenario: Остановленная запись видна отбором
|
||
|
||
- **GIVEN** у владельца есть запись в работе, остановленная запись и прошедшая
|
||
конвейер
|
||
- **WHEN** он спрашивает каждое из трёх состояний отбором
|
||
- **THEN** каждая запись приходит ровно в одном из них
|
||
- **AND** остановленная приходит с признаком остановки и её причиной
|
||
|
||
### Requirement: Карточка записи отдаётся без текста
|
||
|
||
Сервис SHALL отдавать владельцу карточку одной записи — `GET
|
||
/app/audiorecords/{id}` — и MUST не класть в неё текста расшифровки.
|
||
|
||
**Карточка несёт те же поля, что и элемент страницы, плюс перечень доступных
|
||
видов текста** — полем `available_views`. Две формы одной вещи разошлись бы
|
||
молча, поэтому состав задан одной нормой, а не двумя.
|
||
|
||
Отсюда обязанность, которой держится инвариант проекта «принятая запись не
|
||
теряется молча»: карточка MUST нести рубеж, признак остановки и её причину.
|
||
Прежде исход своей записи владелец узнавал опросом готовности; опрос убран, и
|
||
единственным местом, где отправитель узнаёт о неудаче, становится карточка. Норма
|
||
эта переехала сюда целиком — capability `pipeline` называет держателем её этот
|
||
адрес.
|
||
|
||
Вид считается доступным по **содержимому**, а не по наличию ссылки на текст.
|
||
Ссылка без содержимого — состояние штатное: пустой ответ распознавания сервис
|
||
признаёт нормой и записывает его в журнал. Строй мы перечень по ссылкам,
|
||
карточка объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов»
|
||
вечно — приложение опрашивало бы его без конца, а человек видел бы завершённую
|
||
запись, из которой текст «вот-вот появится».
|
||
|
||
Перечень MUST присутствовать в ответе **всегда**, в том числе пустым: отсутствие
|
||
поля и пустой перечень приложение не различит, а значат они разное.
|
||
|
||
Перечень доступных видов MUST быть **перечнем**, а не признаком «текст есть».
|
||
Видов больше одного, и шаг завершения пишет их несколькими операциями: состояние
|
||
«сплошной текст есть, реплик ещё нет» достижимо. Один признак на несколько видов
|
||
отправил бы приложение за репликами, которых нет, — и исход стал бы функцией
|
||
того, в каком месте прервался шаг, а не состояния записи. Пустой перечень значит
|
||
«текста ещё нет».
|
||
|
||
Шестичасовая расшифровка, приехавшая вместе с шапкой записи, задерживает показ
|
||
на мобильной сети на то время, которое человеку не нужно ждать: шапку он читает
|
||
сразу, а текст — если решил читать.
|
||
|
||
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
||
идентификатор.
|
||
|
||
#### Scenario: Карточка без текста
|
||
|
||
- **GIVEN** у записи есть расшифровка
|
||
- **WHEN** владелец спрашивает её карточку
|
||
- **THEN** поля с текстом в ответе нет
|
||
- **AND** перечень доступных видов несёт сырую расшифровку
|
||
|
||
#### Scenario: Остановленная запись видна карточкой
|
||
|
||
- **GIVEN** запись остановлена признаком по исчерпании отказов
|
||
- **WHEN** владелец спрашивает её карточку
|
||
- **THEN** карточка несёт достигнутый рубеж, признак остановки и её причину
|
||
- **AND** машинного текста отказа в ответе нет
|
||
|
||
#### Scenario: Текста ещё нет
|
||
|
||
- **GIVEN** запись не дошла до расшифровки
|
||
- **WHEN** владелец спрашивает её карточку
|
||
- **THEN** перечень доступных видов пуст
|
||
|
||
#### Scenario: Чужая карточка неотличима от неизвестной
|
||
|
||
- **GIVEN** запись заведена одним вошедшим
|
||
- **WHEN** её карточку спрашивает другой вошедший
|
||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||
|
||
### Requirement: Текст записи отдаётся названным видом
|
||
|
||
Сервис SHALL отдавать текст записи отдельным адресом — `GET
|
||
/app/audiorecords/{id}/text` — и MUST отдавать **вид, названный спрашивающим**.
|
||
Отдача «последнего записанного» сделала бы ответ функцией порядка записи, а не
|
||
состояния записи.
|
||
|
||
**Перечень видов закрыт, и каждое его значение называет ровно одну хранимую
|
||
вещь:**
|
||
|
||
- `transcript` — сырая расшифровка сплошным текстом;
|
||
- `literary` — вычитанный текст сплошным;
|
||
- `replicas` — реплики со временем.
|
||
|
||
Перечень назван так, а не парой «вид текста плюс форма показа», потому что
|
||
реплики со временем — не вид текста: они лежат структурой разбора и принадлежат
|
||
записи, а не тексту. Пара из двух параметров обещала бы сочетания, которых не
|
||
существует.
|
||
|
||
Вычитанный текст назван здесь, хотя считает его отдельная задача: перечень,
|
||
заведённый без него, пришлось бы расширять правкой публичного контракта — того
|
||
самого, который согласуется здесь один раз. До появления вычитанного текста
|
||
значение просто не встречается в перечне доступных видов у карточки.
|
||
|
||
Значения `transcript` и `literary` MUST совпадать с видами текста, объявленными
|
||
хранилищем: два словаря об одном разошлись бы молча.
|
||
|
||
Текста запрошенного вида нет — сервис MUST отвечать кодом `409`, а не пустой
|
||
строкой и не `404`. Пустая строка читается как «расшифровка пуста»; `404` слился
|
||
бы с ответом на чужую и неизвестную запись, и человек увидел бы «не найдено» на
|
||
своей записи, загруженной минуту назад, — ровно тот отказ, ради устранения
|
||
которого заводится весь контракт.
|
||
|
||
Вид, которого сервис не знает, и незаданный вид MUST давать отказ по негодному
|
||
вводу: умолчание сделало бы ответ функцией того, что успел записать конвейер.
|
||
|
||
Текст чужой записи MUST быть недоступен наравне с её карточкой.
|
||
|
||
#### Scenario: Сырая расшифровка сплошным текстом
|
||
|
||
- **GIVEN** у записи есть сырая расшифровка
|
||
- **WHEN** владелец спрашивает её текст видом `transcript`
|
||
- **THEN** ответ несёт содержимое сырой расшифровки
|
||
|
||
#### Scenario: Реплики со временем
|
||
|
||
- **GIVEN** у записи есть структура реплик
|
||
- **WHEN** владелец спрашивает её текст видом `replicas`
|
||
- **THEN** ответ несёт реплики, и у каждой стоит её время
|
||
|
||
#### Scenario: Текста этого вида ещё нет
|
||
|
||
- **GIVEN** у записи есть сырая расшифровка и нет структуры реплик
|
||
- **WHEN** владелец спрашивает её текст видом `replicas`
|
||
- **THEN** ответ имеет код `409`
|
||
- **AND** он отличается от ответа на неизвестный идентификатор
|
||
|
||
#### Scenario: Вид неизвестен или не назван
|
||
|
||
- **WHEN** владелец спрашивает текст видом, которого сервис не знает, либо не
|
||
называет вида вовсе
|
||
- **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** ответа из нескольких частей сервис не отдаёт
|