- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
36 KiB
archive Specification
Purpose
TBD - created by archiving change app-json-contract. Update Purpose after archive.
Requirements
Requirement: Адреса приложения живут своим пространством
Сервис SHALL вешать собственные адреса приложения под корнем /app/ и MUST не
занимать имён в пространстве /api/: последнее принадлежит хранилищу, оно
вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он
литерал библиотеки, а не настройка.
Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся: обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом начнёт отвечать не тот обработчик.
Цена переезда называется здесь же. Правило неизвестного пути, по которому приложение отдаётся вместо отказа, MUST перечислять все корни сервиса, а не один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель частоты хранилища настроен на чужой корень и наших адресов больше не покрывает, поэтому сервис MUST заводить своё правило под корень приложения.
Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность и выключен умолчанием, поэтому включение нашего правила вводит в действие и его собственные — на входе, на заведении записей и на его адресах. Принимается сознательно: без включения наше правило не значит ничего.
Пространство /api/settings принадлежит хранилищу и остаётся ему: настройки
человека живут под корнем приложения.
Scenario: Адрес приложения отвечает под своим корнем
- GIVEN человек вошёл и предъявил сессию
- WHEN он спрашивает список своих записей под корнем приложения
- THEN ответ приходит от сервиса, а не от хранилища
Scenario: Прежние адреса приложения не отвечают
- GIVEN заведена запись
- WHEN её спрашивают прежними адресами в чужом пространстве
- THEN ответ имеет код
404
Scenario: Ограничитель частоты покрывает адреса приложения
- WHEN сервис поднялся
- THEN настройки ограничителя несут правило, чей адрес начинается корнем приложения
Requirement: Отказ называет причину, а не место
Сервис SHALL отвечать на адресах приложения кодом, который отвечает причине отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- пришедший не узнан —
401, и он MUST наступать до всякого чтения записи, одинаково для заведённой записи и для неизвестного идентификатора: иначе по разнице кодов перебирается список заведённых записей; - узнанный предъявитель без учётной записи пользователя —
403; - неизвестный идентификатор —
404, и тем же кодом с тем же телом MUST отвечать чужая и ничья запись; - негодный ввод —
400: нечитаемая запись, неизвестное значение параметра, негодный размер страницы; - запись сверх потолка размера —
413, и тело MUST нести предел числом; - состояние, в котором действие недоступно, —
409: текста запрошенного вида у записи ещё нет; - отказ хранилища и всякая неназванная причина —
500.
Отображение доменной ошибки в код и сообщение MUST жить одним местом на все адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как «моя запись пропала», а второе не говорит ему ничего.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести два
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
все три 400, — а приложению надо решать, предлагать ли повтор и что показать
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
задача экрана переписала бы контракт, согласованный здесь один раз.
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и выбранные кодом они стали бы контрактом молча:
- поля тела:
error_codeиmessage; - перечень
error_code:unauthorized,forbidden,not_found,bad_request,too_large,too_many_requests,not_ready,internal.
Часть отказов рождается не в обработчике — предел тела, ограничитель частоты, неизвестный путь под корнем приложения, — и до отображения доменной ошибки не доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на адресах приложения две, а самый частый отказ у человека на мобильной сети — «запись больше потолка» — приходит телом библиотеки, без кода и без предела числом.
Перечень закрыт и объявляется одним местом. Новая штатная ветвь отказа
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
умолчанию отдаст internal на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни err.Error(), ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
остаётся в журнале владельца сервиса.
Scenario: Сбой хранилища виден как сбой
- GIVEN хранилище отвечает отказом драйвера на чтение записи
- WHEN владелец спрашивает свою запись
- THEN ответ имеет код
500 - AND тела записи в ответе нет
Scenario: Негодная запись видна как негодная
- GIVEN источник метаданных не может прочитать присланную запись
- WHEN отправитель шлёт её приёмом
- THEN ответ имеет код
400и несёт сообщение, пригодное человеку - AND причина отказа в тело ответа не попадает
Scenario: Отказ по пустому владельцу
- GIVEN предъявитель узнан, но учётной записи пользователя у него нет
- WHEN он шлёт запись приёмом
- THEN ответ имеет код
403
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