- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
167 lines
10 KiB
Go
167 lines
10 KiB
Go
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))
|
||
}
|