httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/points"
|
||||
)
|
||||
|
||||
// ФОРМА ПРОВОДА ТОЧЕК. Объявлена здесь и только здесь — доменные типы
|
||||
// `internal/points` `json`-тегов не несут и до сериализации не доезжают.
|
||||
// Образец и его цена — `internal/httpapi/catalog.go`; выбирать заново не нужно.
|
||||
|
||||
// pointsResponse — оболочка ответа точек.
|
||||
//
|
||||
// Поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
|
||||
// выводить исход наличием или отсутствием поля. Отсюда указатели у `layer` и
|
||||
// `bucket` — `null` означает «слоя нет» и «свёртки не было», а не пустую
|
||||
// строку, которая была бы законным именем слоя.
|
||||
type pointsResponse struct {
|
||||
Metric string `json:"metric"`
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Layer *string `json:"layer"`
|
||||
// Bucket — сетка свёртки. Всегда `null` в этой версии маршрута: свёртку
|
||||
// делает соседняя задача. Поле объявлено сразу, потому что менять форму
|
||||
// конверта после того, как его скопировали четыре маршрута, — не развилка,
|
||||
// а археология.
|
||||
Bucket *string `json:"bucket"`
|
||||
Aggregation seriesAggregation `json:"aggregation"`
|
||||
Points []pointWire `json:"points"`
|
||||
}
|
||||
|
||||
// seriesAggregation — род свёртки вместе с его применимостью и свежестью.
|
||||
//
|
||||
// `applicable` не украшение: род — свойство МЕТРИКИ, слой — свойство ОТДАННОГО
|
||||
// РЯДА, и сочетание `{"layer": "raw", "style": "cumulative"}` законно, штатно и
|
||||
// прямо приглашает потребителя сложить интерполяцию самому. Система при этом
|
||||
// ничего не складывает, а решение у потребителя уже принято по завышенному
|
||||
// втрое числу.
|
||||
//
|
||||
// `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
|
||||
// ОБЩИХ часах, а не в часах календаря: выключенная минутная автоматизация HAE
|
||||
// останавливает их пополнение, окно замирает и продолжает объявлять род.
|
||||
// Единственный след этого — вот это поле.
|
||||
type seriesAggregation struct {
|
||||
Style string `json:"style"`
|
||||
Applicable bool `json:"applicable"`
|
||||
LastHour *time.Time `json:"last_hour"`
|
||||
}
|
||||
|
||||
// pointWire — точка ряда на проводе.
|
||||
//
|
||||
// `values` уезжает СЫРЫМ JSON: точки хранятся дословно, и переписывать их в тип
|
||||
// провода значило бы нарушить инвариант. Конверт вокруг значения при этом
|
||||
// объявлен и нормализован, как того требует «форма Apple не транслируется».
|
||||
//
|
||||
// `ts_end` есть потому, что идентичность точки — интервал, а не метка: под
|
||||
// одной меткой лежит до трёх записей сна. У точки-измерения он равен `ts`.
|
||||
type pointWire struct {
|
||||
TS time.Time `json:"ts"`
|
||||
TSEnd time.Time `json:"ts_end"`
|
||||
TZOffset int `json:"tz_offset"`
|
||||
Units string `json:"units"`
|
||||
Values json.RawMessage `json:"values"`
|
||||
}
|
||||
|
||||
// pointsWire переводит ряд домена в форму провода.
|
||||
//
|
||||
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
|
||||
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`.
|
||||
func pointsWire(s points.Series) pointsResponse {
|
||||
out := pointsResponse{
|
||||
Metric: s.Metric,
|
||||
From: s.From,
|
||||
To: s.To,
|
||||
Aggregation: seriesAggregation{
|
||||
Style: s.Style.String(),
|
||||
Applicable: s.Applicable,
|
||||
// Указатель переносится КАК УКАЗАТЕЛЬ: разыменование дало бы
|
||||
// `0001-01-01T00:00:00Z` там, где окна не было, а правдоподобная
|
||||
// дата в ответе неотличима от настоящей.
|
||||
LastHour: s.LastHour,
|
||||
},
|
||||
// Непустой срез, а не nil: nil сериализуется в `null`, и клиент
|
||||
// прочитал бы «поля нет» вместо «точек нет».
|
||||
Points: make([]pointWire, 0, len(s.Points)),
|
||||
}
|
||||
if s.Layer != "" {
|
||||
layer := s.Layer
|
||||
out.Layer = &layer
|
||||
}
|
||||
for _, p := range s.Points {
|
||||
out.Points = append(out.Points, pointWire{
|
||||
TS: p.TS,
|
||||
TSEnd: p.End,
|
||||
TZOffset: p.OffsetSeconds,
|
||||
Units: p.Units,
|
||||
Values: p.Raw,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pointsScope — область действия метки ответа: ХЕШ канонизированной формы
|
||||
// запроса.
|
||||
//
|
||||
// Живёт В ТРАНСПОРТЕ, и это решение, а не случайность: у каталога область тоже
|
||||
// транспортная (`scopeMetrics`), а помощник `etag` прямо просит маршрут положить
|
||||
// сюда канонизированную форму запроса. Считай её домен — у одного понятия
|
||||
// оказалось бы два дома, и три следующих маршрута выбирали бы между ними
|
||||
// монетой. Заодно домен перестал бы носить транспортный артефакт: префикс и hex
|
||||
// — это форма метки HTTP, а форму провода объявляет транспорт (ADR).
|
||||
//
|
||||
// Метка действительна в пределах ОДНОГО набора данных, а ответ этого маршрута
|
||||
// есть функция параметров. Метка без них однажды подтвердила бы неизменность
|
||||
// чужого набора — молча и без следов.
|
||||
//
|
||||
// Канонизация: границы приводятся к UTC в RFC 3339 с ПОЛНОЙ точностью. Полной,
|
||||
// а не посекундной, потому что отбор точек идёт по полной метке: два запроса,
|
||||
// различающиеся долей секунды, дают разные ряды, и одна область на них
|
||||
// означала бы `304` на чужом наборе данных. Зона при этом канонизируется —
|
||||
// одно и то же время, записанное разными смещениями, даёт одну область.
|
||||
//
|
||||
// Слой берётся ЗАПРОШЕННЫЙ, а не выбранный: областью является запрос, а смену
|
||||
// выбранного слоя от новых данных ловит версия витрины.
|
||||
//
|
||||
// ХЕШ, а не сама форма, и обе причины из `docs/security.md`. Имя метрики
|
||||
// приходит из чужого тела дословно и ничем не ограничено — в заголовке ответа
|
||||
// оно дало бы `ETag` в тысячи байт, притом что тот же вход в лог уезжает
|
||||
// обрезанным. И оно законно содержит кавычку, которая по RFC 9110 кончает
|
||||
// метку: собственный `scanETag` проекта обрезал бы её ровно там, и условный
|
||||
// запрос по такой метрике не сработал бы никогда, а симптом увёл бы отладку в
|
||||
// помощника. Длина впереди остаётся внутри хешируемой строки: без неё имя с
|
||||
// разделителем склеилось бы с соседним полем.
|
||||
func pointsScope(req points.Request) string {
|
||||
canonical := fmt.Sprintf("%d:%s|%s|%s|%s",
|
||||
len(req.Metric), req.Metric,
|
||||
req.From.UTC().Format(time.RFC3339Nano),
|
||||
req.To.UTC().Format(time.RFC3339Nano),
|
||||
req.Layer)
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return "points." + hex.EncodeToString(sum[:scopeBytes])
|
||||
}
|
||||
|
||||
// scopeBytes — сколько байтов хеша попадает в область.
|
||||
//
|
||||
// Шестнадцать: 128 бит, то есть столкновение неотличимо от невозможного, а
|
||||
// заголовок остаётся короче метки каталога. Хеш здесь не криптографический
|
||||
// секрет — он ограничитель длины и экранирование разом.
|
||||
const scopeBytes = 16
|
||||
|
||||
// handlePoints отдаёт точки метрики за период.
|
||||
func (a *api) handlePoints(w http.ResponseWriter, r *http.Request) {
|
||||
req, msg := parsePointsRequest(r)
|
||||
if msg != "" {
|
||||
// Отказ до чтения витрины: невозможный запрос не должен стоить снимка.
|
||||
// В сообщении нет ни одного значения из запроса — оно уедет клиенту, а
|
||||
// в запросе имя метрики и границы периода.
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
series, err := a.points.Series(r.Context(), req)
|
||||
if err != nil {
|
||||
// Исход операции логирует доменный слой, транспорт только переводит его
|
||||
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
|
||||
// в нём имена колонок и форма запроса.
|
||||
writeError(w, http.StatusInternalServerError, "ряд точек не собрался")
|
||||
return
|
||||
}
|
||||
|
||||
// Область действия метки — канонизированная форма запроса: ответ этого
|
||||
// маршрута есть функция параметров, и метка без них однажды подтвердила бы
|
||||
// неизменность чужого набора данных. Горизонт измерения в метке уже есть —
|
||||
// его кладёт домен вместе с версией витрины.
|
||||
setReadHeaders(w, etag(pointsScope(req), series.Version))
|
||||
if err := writeJSON(w, http.StatusOK, pointsWire(series)); err != nil {
|
||||
// Тело оборвалось на середине: дедлайн записи, ушедший клиент, полный
|
||||
// буфер посредника. Код ответа уже отдан, и `accessLog` напишет `200` —
|
||||
// то есть единственный канал наблюдаемости сообщит успех о неотданном
|
||||
// ответе. Ряд ничем не ограничен по размеру (предел — соседняя задача),
|
||||
// поэтому случай не гипотетический: неделя нижнего слоя это 280 МиБ.
|
||||
//
|
||||
// WARN, а не DEBUG, и это исправление находки эксплуатационного прохода:
|
||||
// боевой уровень логирования — `info` (`config.docker.toml`), то есть
|
||||
// запись уровня `DEBUG` не прошла бы фильтр НИКОГДА, и единственный
|
||||
// признак недоставленного тела не существовал бы в проде вовсе.
|
||||
// Обесценивания уровня здесь нет: событие не периодическое — оно
|
||||
// означает, что потребитель получил битый JSON.
|
||||
//
|
||||
// Значений точек и границ запроса в записи нет; имя метрики обрезано.
|
||||
a.log.WarnContext(r.Context(), "points response truncated",
|
||||
"capability", "query", "metric", catalog.ClipMetric(req.Metric), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parsePointsRequest разбирает параметры маршрута точек.
|
||||
//
|
||||
// Второй возврат — человекочитаемая причина отказа; пустая строка означает, что
|
||||
// запрос принят. Строка, а не ошибка: она целиком уезжает клиенту, поэтому
|
||||
// обязана быть составлена здесь и не содержать ни одного значения из запроса.
|
||||
func parsePointsRequest(r *http.Request) (points.Request, string) {
|
||||
q := r.URL.Query()
|
||||
|
||||
// Свёртка по сетке ещё не поддержана, и молчать об этом нельзя: клиент,
|
||||
// попросивший суточную сетку и получивший полный минутный ряд, заметил бы
|
||||
// подмену, только пересчитав точки. Это зеркало того самого промаха, ради
|
||||
// которого «сетка задана явно» вообще различается.
|
||||
if q.Has("bucket") {
|
||||
return points.Request{}, "свёртка по сетке ещё не поддержана: параметр bucket не принимается"
|
||||
}
|
||||
|
||||
from, msg := parseBound(q, "from")
|
||||
if msg != "" {
|
||||
return points.Request{}, msg
|
||||
}
|
||||
to, msg := parseBound(q, "to")
|
||||
if msg != "" {
|
||||
return points.Request{}, msg
|
||||
}
|
||||
if !from.Before(to) {
|
||||
return points.Request{}, "период пуст: from должен быть строго раньше to"
|
||||
}
|
||||
|
||||
// `Has`, а не `Get() != ""`, и симметрично `bucket`. Пустое значение
|
||||
// (`?layer=`) — самый частый способ промахнуться: шаблон клиента с
|
||||
// невыставленной переменной. Разбор по непустоте молча включил бы
|
||||
// автоматический выбор, и клиент, спросивший разрез поимённо, не отличил бы
|
||||
// свой промах от ответа по существу.
|
||||
layer := q.Get("layer")
|
||||
if q.Has("layer") && !hae.Known(hae.Layer(layer)) {
|
||||
return points.Request{}, "неизвестный слой: допустимы " + knownLayers
|
||||
}
|
||||
|
||||
return points.Request{Metric: metricFromPath(r), From: from, To: to, Layer: layer}, ""
|
||||
}
|
||||
|
||||
// parseBound разбирает границу периода.
|
||||
//
|
||||
// Только RFC 3339 и только с явным смещением зоны. Голая дата отвергается не из
|
||||
// строгости: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте
|
||||
// открыт отдельной задачей. Принять её значило бы выбрать зону за клиента молча.
|
||||
func parseBound(q url.Values, name string) (time.Time, string) {
|
||||
raw := q.Get(name)
|
||||
if raw == "" {
|
||||
return time.Time{}, "параметр " + name + " обязателен"
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, "параметр " + name +
|
||||
": ожидается метка времени RFC 3339 с явным смещением зоны, например 2026-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
// Год после приведения к UTC обязан остаться четырёхзначным, и это не
|
||||
// придирка к календарю. Хранилище адресует объекты строкой RFC 3339, а
|
||||
// сравнение границ идёт лексикографически: `9999-12-31T23:00:00-07:00`
|
||||
// превращается в `10000-01-01T06:00:00Z`, который меньше любой настоящей
|
||||
// метки как строка, — и запрос молча отдал бы пустой ряд при непустых
|
||||
// данных. Отказ здесь честнее пустоты: тот же разряд ловится и на входе
|
||||
// приёма, но там он унаследован и лечится не тут.
|
||||
if y := t.UTC().Year(); y < 1 || y > 9999 {
|
||||
return time.Time{}, "параметр " + name + ": год после приведения к UTC вне диапазона 1–9999"
|
||||
}
|
||||
return t.UTC(), ""
|
||||
}
|
||||
|
||||
// knownLayers — перечень допустимых слоёв ДЛЯ СООБЩЕНИЯ КЛИЕНТУ, собранный из
|
||||
// того же словаря, что и проверка. Текст отказа уезжает наружу и потому обязан
|
||||
// перечислять ровно то, что принимается: разъехавшись, он врал бы клиенту.
|
||||
var knownLayers = func() string {
|
||||
out := make([]string, 0, len(hae.Layers))
|
||||
for _, l := range hae.Layers {
|
||||
out = append(out, string(l))
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}()
|
||||
|
||||
// metricFromPath достаёт имя метрики из пути.
|
||||
//
|
||||
// Решение принимается по `RawPath`, а НЕ по успеху декодирования, и это ровно
|
||||
// то место, где легко ошибиться. `chi` сопоставляет по сырому пути только когда
|
||||
// тот непуст, то есть когда `net/url` увидел в пути экранирование; тогда
|
||||
// `{metric}` приезжает закодированным и его надо декодировать. Когда `RawPath`
|
||||
// пуст, `chi` отдаёт уже декодированное имя — и второе декодирование испортило
|
||||
// бы его молча.
|
||||
//
|
||||
// Цена ошибки построена и прогнана враждебным проходом: метрика с именем
|
||||
// `a%41b` (имена приходят из тела доставки дословно) кодируется клиентом в
|
||||
// `a%2541b`, `net/url` декодирует это в `a%41b` и оставляет `RawPath` пустым —
|
||||
// а второе декодирование давало `aAb`, то есть маршрут отвечал `200` и данными
|
||||
// ДРУГОЙ метрики.
|
||||
//
|
||||
// Отказ декодирования не является отказом маршрута: берём имя как есть — оно
|
||||
// всё равно не совпадёт ни с одной метрикой витрины, и клиент получит честный
|
||||
// пустой ряд, а не `400` на своё же имя.
|
||||
func metricFromPath(r *http.Request) string {
|
||||
raw := chi.URLParam(r, "metric")
|
||||
if r.URL.RawPath == "" {
|
||||
return raw
|
||||
}
|
||||
if decoded, err := url.PathUnescape(raw); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user