httpapi: форма провода читающих маршрутов объявлена транспортом

- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
This commit is contained in:
av
2026-08-04 16:18:05 +03:00
parent bd832337df
commit a834d10415
19 changed files with 1721 additions and 53 deletions
+105 -8
View File
@@ -2,26 +2,123 @@ package httpapi
import (
"net/http"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
)
// ФОРМА ПРОВОДА КАТАЛОГА. Публичный контракт объявлен здесь и только здесь —
// доменные типы `internal/catalog` `json`-тегов не несут и до сериализации не
// доезжают. Отсюда следует то, ради чего это сделано: переименование поля в
// домене байты ответа не меняет, а смена контракта есть правка вот этих
// объявлений, то есть действие, а не побочный эффект.
//
// Обратная цена взята сознательно: новое поле домена в ответ само не попадёт —
// его обязан перечислить перевод ниже. Решение, цена обеих сторон и разбор
// чужих решений — `docs/architecture.md`, раздел «Read API», подраздел «Форма
// провода».
//
// ОБРАЗЕЦ ДЛЯ СЛЕДУЮЩИХ МАРШРУТОВ: точки, тренировки, записи и MCP объявляют
// свою форму так же — типами рядом с обработчиком, переводом-присваиванием,
// строкой в таблице `wire_internal_test.go`. Выбирать заново не нужно.
// catalogResponse — оболочка ответа каталога.
//
// Объект, а не голый массив: список метрик — не единственное, что каталогу
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
type catalogResponse struct {
Metrics []catalog.Metric `json:"metrics"`
Metrics []metricWire `json:"metrics"`
}
// metricWire — запись каталога на проводе.
type metricWire struct {
Metric string `json:"metric"`
Units []string `json:"units"`
Aggregation aggregationWire `json:"aggregation"`
Layers []layerWire `json:"layers"`
}
// aggregationWire — род агрегации вместе с основанием, ПЛОСКО.
//
// Поля основания перечислены здесь поимённо, а не встроены структурой: в домене
// `Basis` встроен в `Aggregation`, и плоскость объекта была следствием этого
// встраивания — разъединение в домене молча дало бы клиенту вложенный объект.
// Теперь плоскость — записанное решение транспорта.
//
// Род — обычная `string`: словарь (`cumulative`/`instant`/`unknown`) клиент
// видит строкой, и кладёт её сюда транспорт. Значения словаря при этом остаются
// доменные (`catalog.Style.String()`) — свой `switch` здесь завёл бы второй
// словарь, который разошёлся бы с первым молча.
type aggregationWire struct {
Style string `json:"style"`
Hours int `json:"hours"`
Compared int `json:"compared"`
Agreeing int `json:"agreeing"`
Conflicting int `json:"conflicting"`
FirstHour *time.Time `json:"first_hour"`
LastHour *time.Time `json:"last_hour"`
}
// layerWire — разрез метрики на проводе.
type layerWire struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
}
// catalogWire переводит записи домена в форму провода.
//
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`, и снимок у тела и у
// метки один, иначе `304` подтверждал бы одно состояние, а `200` отдавал другое.
//
// Присваивание поле в поле, а не копирование структуры: это и есть то место, где
// смена контракта становится видимой правкой.
func catalogWire(metrics []catalog.Metric) catalogResponse {
// Непустой срез, а не nil: nil сериализуется в `null`, и пустая витрина
// отдавала бы клиенту `"metrics": null` вместо `[]`. Домен это уже
// обеспечивает, но контракт объявлен здесь — значит и держится здесь.
out := make([]metricWire, 0, len(metrics))
for _, m := range metrics {
layers := make([]layerWire, 0, len(m.Layers))
for _, l := range m.Layers {
layers = append(layers, layerWire{
Layer: l.Layer,
From: l.From,
To: l.To,
Points: l.Points,
})
}
// Так же, как слои: `make` + `append`, а не копирование среза домена.
// Ветвления «если nil» здесь нет намеренно — оно было бы веткой, которую
// сегодня не проходит ни один вход (домен nil не отдаёт), то есть
// непроверяемой страховкой. Конструкция даёт непустой срез всегда.
units := make([]string, 0, len(m.Units))
units = append(units, m.Units...)
out = append(out, metricWire{
Metric: m.Metric,
Units: units,
Aggregation: aggregationWire{
Style: m.Aggregation.Style.String(),
Hours: m.Aggregation.Hours,
Compared: m.Aggregation.Compared,
Agreeing: m.Aggregation.Agreeing,
Conflicting: m.Aggregation.Conflicting,
// Указатели переносятся КАК УКАЗАТЕЛИ: разыменование дало бы
// `0001-01-01T00:00:00Z` там, где окно не измерено, а
// правдоподобная дата в ответе неотличима от настоящей.
FirstHour: m.Aggregation.FirstHour,
LastHour: m.Aggregation.LastHour,
},
Layers: layers,
})
}
return catalogResponse{Metrics: out}
}
// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
//
// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и
// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест,
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
// подстраховка поверх подстраховки прячет отказ первой.
//
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
@@ -62,5 +159,5 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
// ответ уходит без метки: это ровно поведение до появления условного
// запроса, то есть деградация в безопасную сторону.
setReadHeaders(w, etag(scopeMetrics, snap.Version))
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
}