- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
27 KiB
ADDED Requirements
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 ответа из нескольких частей сервис не отдаёт
MODIFIED 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 ни файла, ни аудиозаписи не заводится