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

12 KiB

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