Files
healthlog/internal/httpapi/catalog.go
T
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

167 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}