- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
25 KiB
webapp Specification
Purpose
Приложение в браузере: чем сервис его отдаёт, каким адресом оно открывается, что делает обновление страницы посреди него и что человек видит, открыв его.
Спека отвечает за сервис, а не за сборщик: правило неизвестного пути, срок хранения ответов, поведение при несобранном приложении и то, что уходит в журнал. Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
Requirements
Requirement: Приложение отдаётся самим бинарником
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника. Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего веб-сервера под раздачу MUST не заводиться.
Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным. Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог либо забыли положить, либо положили не тот, и различить это снаружи нечем.
Scenario: Приложение открывается у бинарника без каталога рядом
- GIVEN бинарник запущен в каталоге, где нет ничего, кроме его настроек
- WHEN браузер спрашивает корень сервиса
- THEN ответ имеет код
200 - AND тело ответа — разметка приложения
Scenario: Ресурс приложения отдаётся оттуда же
- GIVEN разметка приложения названа своим ресурсом
- WHEN браузер спрашивает этот ресурс
- THEN ответ имеет код
200 - AND тело ответа — содержимое ресурса
Requirement: Сервис, поднятый без собранного приложения, говорит об этом
Сервис SHALL отвечать кодом 503 на всяком пути, где он отдал бы разметку, если
собранного приложения в нём нет, и MUST писать об этом строку в журнал при
подъёме. Отвечать 404 и молчать он MUST не вправе: «приложения нет» и «такого
адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в
адресе.
Состояние это возможно только у собранного мимо набора проверок: и набор проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в этом состоянии принимает записи и расшифровывает их — не работает только показ.
Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса
- GIVEN бинарник собран без собранного приложения
- WHEN браузер спрашивает корень сервиса
- THEN ответ имеет код
503
Scenario: Отсутствие сборки видно в журнале
- GIVEN бинарник собран без собранного приложения
- WHEN сервис поднимается
- THEN в журнале есть строка о том, что приложение не собрано
Scenario: Проба здоровья остаётся зелёной
- GIVEN бинарник собран без собранного приложения
- WHEN запрос приходит на пробу здоровья
- THEN ответ имеет код
200
Requirement: Неизвестный путь вне корней открывает приложение
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у
сервиса остался один — /app у приложения; отдельными адресами стоят
/health и /metrics.
Корней /api и /_ в перечне больше нет: встроенного хранилища с его
собственным пространством и панели администратора у сервиса не осталось, и
адресов под этими именами не существует. Прежние пути хранилища и панели поэтому
отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит,
ничем не отличается от любого другого свободного имени, а второй перечень
«когда-то занятых корней» разошёлся бы с первым молча.
Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака, раскодируется в тот же путь и попадает в то же правило — правило одно, и особого случая у него нет.
Корня /auth в перечне нет тоже: собственного входа у сервиса не осталось.
Путь принадлежит корню, когда совпадает с ним точно либо начинается им вместе с
косой чертой. Оба условия обязательны: по одному лишь префиксу корню /app
достался бы посторонний /apple, а по одному лишь префиксу с косой чертой голый
/app не достался бы никому и уехал бы разметкой.
Чем отвечает голый /app, названо прямо: он принадлежит корню приложения,
адресом приложения при этом не является и потому MUST отвечать как неизвестный
путь под корнем приложения — узнанному 404 телом отказа приложения,
неузнанному 401 тем же телом, каким отвечают прочие адреса под этим корнем.
Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка
приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая
же сборка.
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
контракта остаётся отказом контракта и уходит той формой, которой этот корень
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
получила бы разметку с кодом 200 вместо отказа с машиночитаемым кодом — и
приняла бы её за ответ.
Путь под каталогом ресурсов — тем, который наполняет сборщик, — разметкой не
подменяется: не совпавший с файлом, он MUST отвечать 404. Иначе разметка
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
него 200 и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
Открывающими страницу считаются GET и HEAD, и только они; прочие методы MUST
отвечать 405.
Scenario: Обновление страницы посреди приложения открывает тот же экран
- GIVEN приложение открыто на своём маршруте
- WHEN браузер спрашивает этот путь заново
- THEN ответ имеет код
200 - AND тело ответа — разметка приложения
Scenario: Голый корень разметкой не подменяется
- GIVEN запрос идёт с заголовком, поставленным прокси
- WHEN запрос приходит на путь, совпадающий с корнем приложения точно и без косой черты
- THEN ответ имеет код
404 - AND тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем
- WHEN запрос приходит без заголовка на путь, совпадающий с корнем приложения точно и без косой черты
- THEN ответ имеет код
401 - AND тело ответа — не разметка приложения
Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
- WHEN запрос приходит на путь, который начинается именем корня, но не отделён от него косой чертой
- THEN ответ имеет код
200 - AND тело ответа — разметка приложения
Scenario: Прежний адрес входа открывает приложение
- WHEN запрос приходит на путь под прежним корнем входа
- THEN ответ имеет код
200 - AND тело ответа — разметка приложения
Scenario: Прежний путь хранилища открывает приложение
- WHEN запрос приходит на путь под прежним корнем хранилища
- THEN ответ имеет код
200 - AND тело ответа — разметка приложения
Scenario: Прежний адрес панели открывает приложение
- WHEN запрос приходит на прежний адрес панели — и записанный знаком, и записанный кодом этого знака
- THEN оба ответа имеют код
200 - AND тело каждого — разметка приложения
Scenario: Неизвестный путь под корнем приложения отвечает отказом
- GIVEN запрос идёт с заголовком, поставленным прокси
- WHEN он спрашивает неизвестный путь под корнем приложения
- THEN ответ имеет код
404 - AND тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- WHEN запрос приходит на неизвестный путь под корнем приложения без заголовка
- THEN ответ имеет код
401 - AND тело ответа — не разметка приложения
Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- WHEN браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
- THEN ответ имеет код
404 - AND тело ответа — не разметка приложения
Requirement: Обновлённое приложение доходит до браузера
Сервис SHALL отдавать ресурс из каталога, который наполняет сборщик, с долгим сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс приезжает под новым именем и прежний ответ устареть не может.
Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка, манифест и всякий файл с постоянным именем меняются под тем же именем, и отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт до того, кто уже открывал приложение, пока он не почистит хранилище браузера руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит.
Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит. Здесь названо только то, что делает с ними сам сервис.
Scenario: Ресурс сборщика отдаётся с долгим сроком
- WHEN браузер спрашивает ресурс из каталога, который наполняет сборщик
- THEN ответ несёт долгий срок хранения и пометку «неизменяемо»
Scenario: Разметка спрашивается заново
- WHEN браузер спрашивает разметку приложения
- THEN ответ несёт требование спрашивать её заново
- AND пометки «неизменяемо» в ответе нет
Scenario: Файл с постоянным именем долгого срока не получает
- WHEN браузер спрашивает файл сборки, лежащий вне каталога ресурсов
- THEN ответ несёт требование спрашивать его заново
Requirement: Путь, отданный приложению, в журнал не идёт
Сервис SHALL записывать о запросе маршрут из закрытого перечня и длину пути, а самого запрошенного пути MUST не записывать ни в один свой журнал. То же относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного приложению, к строке добавляется исход из закрытого перечня — разметка, ресурс, отказ.
Правило MUST накрывать обе половины адресного пространства — и путь вне корней
сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не
меняет: /app/<произвольный текст> принадлежит сервису и отвечает отказом, но
множеством значений под корнем распоряжается спрашивающий ровно так же, как и
вне его.
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к перечню известных.
Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и пишет он прочитанный идентификатор, а не тот, что стоял в запросе.
Журнал у сервиса теперь один: второй, куда чужая библиотека клала путь целиком вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а перестало зависеть от настройки чужого журнала, которую мы не писали.
Scenario: Путь не доезжает до журнала
- WHEN приходит запрос на путь вне корней сервиса
- THEN записи о нём не несут этого пути
- AND несут исход и длину пути
Scenario: Длинный путь журнал не наполняет
- WHEN приходит запрос на путь длиной в тысячу знаков
- THEN записи о нём не растут вместе с длиной пути
Scenario: Путь под корнем приложения журнал не пишет
- GIVEN пришедший не узнан
- WHEN он спрашивает под корнем приложения путь, не отвечающий ни одному объявленному образцу адреса
- THEN записи о нём не несут этого пути
- AND не растут вместе с его длиной
Scenario: Объявленный образец адреса приложения в журнале различим
- WHEN приходит запрос на объявленный адрес приложения
- THEN запись о нём несёт образец этого адреса, а не запрошенный путь
Scenario: Второго журнала у сервиса нет
- GIVEN сервис поднялся
- WHEN приходит запрос на путь вне корней сервиса
- THEN запись о нём появляется только в журнале сервиса
Requirement: Сервис объявляет, какая сборка приложения в нём вшита
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её заново, и без метки браузер получает полное тело вместо подтверждения — вшитый файл не несёт времени правки вовсе.
Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия, а искать причину человек идёт в код сервиса.
Scenario: Отпечаток виден при подъёме
- WHEN сервис поднимается с собранным приложением
- THEN в журнале есть отпечаток вшитой сборки
Scenario: Разметка несёт метку ответа
- WHEN браузер спрашивает разметку приложения
- THEN ответ несёт метку, по которой её можно спросить заново
Requirement: Открытое приложение показывает вошедшего
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
401 MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий иной отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ 401 уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме access.
Scenario: Узнанный виден
- GIVEN запрос приложения идёт с заголовком, поставленным прокси
- WHEN он открывает приложение
- THEN приложение показывает его имя
Scenario: Неузнанному показывают, что его не узнали
- GIVEN сервис отвечает на вопрос о пришедшем кодом
401 - WHEN человек открывает приложение
- THEN приложение показывает строку о том, что его не узнали
- AND никуда его не уводит
Scenario: Отказ сервиса от неузнавания отличается
- GIVEN сервис отвечает на вопрос о пришедшем отказом, который не является неузнаванием
- WHEN человек открывает приложение
- THEN приложение показывает строку о неудаче
- AND эта строка не та, которой оно сообщает о неузнавании