Files
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

150 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`