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