- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
150 lines
12 KiB
Markdown
150 lines
12 KiB
Markdown
# read-api Specification
|
||
|
||
## Purpose
|
||
|
||
Общие правила читающих маршрутов — то, что у каталога, точек, тренировок,
|
||
записей, статистики и MCP одинаково и потому не должно решаться каждым заново.
|
||
Форма конкретного ответа принадлежит capability самого маршрута; здесь — правила
|
||
поверх них.
|
||
|
||
Первое и главное: **публичный контракт объявляет транспорт.** Форма ответа не
|
||
выводится из формы доменных типов, поэтому её смена есть правка транспортного
|
||
слоя — действие, а не побочный эффект переименования поля в домене. Цена
|
||
названа и она обратная: новое поле домена в ответ само не попадёт.
|
||
|
||
## Requirements
|
||
### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта
|
||
|
||
Система SHALL объявлять форму ответа каждого читающего маршрута типами
|
||
транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип
|
||
домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода
|
||
MUST быть явным перечислением полей.
|
||
|
||
**Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог,
|
||
точки, тренировки, записи, статистика. `/healthz` под правило не подпадает —
|
||
его тело есть литеральный признак живости процесса, а не данные.
|
||
|
||
**MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те
|
||
же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма
|
||
у контракта одна на оба транспорта. Второе объявление развело бы их молча.
|
||
|
||
Правило распространяется и на **тело отказа**: у читающего маршрута оно часть
|
||
того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST
|
||
собираться объявленным типом транспорта, а не картой и не свободной строкой.
|
||
|
||
**Следствие, ради которого правило и существует:** переименование поля
|
||
доменного типа, разъединение встроенной в него структуры и появление в нём
|
||
нового поля байты ответа не меняют — до сериализации доменный тип не доезжает.
|
||
Смена публичного контракта становится правкой транспортного слоя, то есть
|
||
действием, а не побочным эффектом.
|
||
|
||
**Цена названа и взята сознательно, потому что она обратная.** Новое поле
|
||
домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его
|
||
перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже
|
||
ровно на объём перевода.
|
||
|
||
Из правила есть одно исключение, и оно ограничено **содержимым**, а не
|
||
конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым
|
||
JSON без разбора и переобъявления — переписывать его в тип провода значило бы
|
||
нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения —
|
||
метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется,
|
||
как того требует инвариант «форма Apple не транслируется».
|
||
|
||
#### Scenario: Поле доменного типа переименовано
|
||
|
||
- **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а
|
||
транспортный слой не тронут
|
||
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
|
||
- **THEN** байты ответа те же
|
||
|
||
#### Scenario: Форма провода изменена намеренно
|
||
|
||
- **GIVEN** транспорт переименовал поле объявленной формы
|
||
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
|
||
- **THEN** байты ответа изменились, и изменение целиком лежит в правке
|
||
транспортного слоя
|
||
|
||
#### Scenario: Читающий маршрут отвечает отказом
|
||
|
||
- **WHEN** читающий маршрут отвечает кодом отказа
|
||
- **THEN** тело отказа собрано объявленным типом транспорта
|
||
|
||
#### Scenario: Словарь значения виден клиенту строкой
|
||
|
||
- **GIVEN** доменное перечисление, чьи значения клиент видит строками
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не
|
||
несёт
|
||
|
||
#### Scenario: Дословное содержимое проходит насквозь
|
||
|
||
- **WHEN** в ответ идёт значение, сохранённое хранилищем дословно
|
||
- **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода
|
||
|
||
### Requirement: Пустая коллекция чтения — список, а не отсутствие
|
||
|
||
Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT
|
||
отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и
|
||
MUST NOT подменяться нулевым значением своего типа.
|
||
|
||
Правило общее для всех читающих маршрутов, потому что цена у него одна:
|
||
`nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом
|
||
деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от
|
||
настоящей. Конкретные коллекции и поля каждого маршрута нормирует его
|
||
собственная capability — здесь только правило, чтобы следующий маршрут не
|
||
принимал его заново.
|
||
|
||
Ручной перевод домена в форму провода делает это правило не теоретическим:
|
||
присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так,
|
||
как написано, и обе ошибки молчат.
|
||
|
||
#### Scenario: Коллекция ответа пуста
|
||
|
||
- **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента
|
||
- **THEN** она уезжает как `[]`
|
||
|
||
#### Scenario: Значения нет
|
||
|
||
- **WHEN** поле ответа не имеет значения
|
||
- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая
|
||
строка или ноль
|
||
|
||
### Requirement: Сериализация ответа чтения не экранирует содержимое
|
||
|
||
Система SHALL сериализовать тела ответов читающих маршрутов **без экранирования
|
||
HTML-символов**: `&`, `<` и `>` внутри значений MUST уезжать клиенту как есть и
|
||
MUST NOT подменяться escape-последовательностями `&`, `<`, `>`.
|
||
|
||
Правило общее для всех читающих маршрутов, а не частное для точек, потому что
|
||
общим является механизм: тело собирает один помощник сериализации, и умолчание
|
||
`encoding/json` экранирует эти три символа молча. На маршруте, отдающем
|
||
**дословно сохранённое** содержимое, это прямо ломает обещание дословности:
|
||
имя источника приходит с телефона пользовательской строкой и законно содержит
|
||
`&`. Хранилище этот же капкан уже проходило и обезвредило тем же способом —
|
||
кодировщик с выключенным экранированием вместо `json.Marshal`.
|
||
|
||
Проверка обязана стоять на содержимом, реально несущем эти символы: набор
|
||
фикстур, в котором их нет, зелен и будучи сломанным.
|
||
|
||
#### Scenario: Значение несёт символ, который сериализатор склонен экранировать
|
||
|
||
- **GIVEN** значение ответа читающего маршрута, содержащее `&`, `<` или `>`
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** эти символы присутствуют в теле ответа как есть
|
||
|
||
### Requirement: Ответ чтения непригоден для разделяемого кеша
|
||
|
||
Система SHALL помечать ответы читающих маршрутов заголовком
|
||
`Cache-Control: private, no-cache`.
|
||
|
||
Правило общее, потому что цена у него одна на все маршруты чтения: с появлением
|
||
валидатора ответ становится штатно кешируемым, а при выключенной проверке
|
||
токенов — законной конфигурации для доверенной локальной сети — в запросе нет и
|
||
`Authorization`. Тогда выгрузку истории здоровья вправе сохранить любой прокси
|
||
на пути. Маршрут, решающий это заново, однажды решит иначе.
|
||
|
||
#### Scenario: Читающий маршрут ответил
|
||
|
||
- **WHEN** читающий маршрут отдаёт тело
|
||
- **THEN** ответ несёт `Cache-Control: private, no-cache`
|