Files
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

317 lines
19 KiB
Go
Raw Permalink 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 (
"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
}