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
+33 -21
View File
@@ -63,12 +63,10 @@ func (s Style) String() string {
}
}
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
// словаре нет.
func (s Style) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}
// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
// сообщениям тестов, и проводу — второго словаря заводить незачем.
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе.
@@ -156,6 +154,16 @@ func clipMetric(metric string) string {
return metric[:maxMetricInLog] + "…"
}
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
//
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
// «Read API», подраздел «Форма провода».
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой
// пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с
@@ -165,17 +173,21 @@ func clipMetric(metric string) string {
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса.
type Basis struct {
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"`
Hours int
Compared int
Agreeing int
Conflicting int
FirstHour *time.Time
LastHour *time.Time
}
// Aggregation — род вместе с основанием.
//
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
// `aggregation` на проводе объявлена транспортом поимённо и от этого
// встраивания не зависит.
type Aggregation struct {
Style Style `json:"style"`
Style Style
Basis
}
@@ -186,22 +198,22 @@ type Aggregation struct {
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает.
type LayerRange struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
Layer string
From time.Time
To time.Time
Points int
}
// Metric — запись каталога.
type Metric struct {
Metric string `json:"metric"`
Metric string
// Units — множество различных единиц метрики, отсортированное. Массив, а не
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
// для обоих случаев честнее строки, которая при расхождении молча выберет
// одно из двух.
Units []string `json:"units"`
Aggregation Aggregation `json:"aggregation"`
Layers []LayerRange `json:"layers"`
Units []string
Aggregation Aggregation
Layers []LayerRange
}
// Snapshot — каталог вместе с версией ответа.