httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
This commit is contained in:
+105
-8
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user