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