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 []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 отдаёт каталог разрезов с измеренным родом агрегации. // // Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки // снимка: в этом весь смысл — самый частый запрос потребителя есть повтор // неизменившегося, и он не должен стоить ни снимка, ни разжатия точек. // scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от // параметров запроса, поэтому область постоянна; у точек и MCP на её месте // будет канонизированная форма запроса. const scopeMetrics = "metrics" func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) { // Values, а не Get: `If-None-Match` клиент вправе прислать несколькими // строками, и `Get` увидел бы только первую — часть меток осталась бы // нерассмотренной. if cond := r.Header.Values("If-None-Match"); len(cond) > 0 { // Отказ пробы глушится намеренно: он означает лишь, что условного // ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога // строкой ниже и будет назван ею один раз. // Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не // только состояния витрины, и что ещё в него входит, знает домен. Read // API точек ответит здесь же своей версией, включающей параметры // запроса, — транспорту эти правила знать незачем. if version, err := a.catalog.Version(r.Context()); err == nil { if tag := etag(scopeMetrics, version); notModified(cond, tag) { writeNotModified(w, tag) return } } } snap, err := a.catalog.Metrics(r.Context()) if err != nil { // Исход операции логирует доменный слой, транспорт только переводит его // в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки: // в нём имена колонок и форма запроса. writeError(w, http.StatusInternalServerError, "каталог не собрался") return } // Версии может не быть — витрина изменилась, пока ответ собирался. Тогда // ответ уходит без метки: это ровно поведение до появления условного // запроса, то есть деградация в безопасную сторону. setReadHeaders(w, etag(scopeMetrics, snap.Version)) writeJSON(w, http.StatusOK, catalogWire(snap.Metrics)) }