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 }