Files
transcriber/openspec/specs/archive/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

53 KiB
Raw Blame History

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 ответа из нескольких частей сервис не отдаёт