Files
healthlog/internal/ingest/ingest.go
T
av 41f2f05f2f добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой
  архив, учёт доставки в SQLite
- код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое
  содержимое — 200, данные уже сохранены и доразберутся позже
- сохраняется полный набор заголовков запроса с вычисткой секретов по имени
  и по совпадению значения с токеном
2026-08-01 12:37:17 +03:00

163 lines
6.6 KiB
Go
Raw 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 ingest — use-case приёма пакета от Health Auto Export, общий для
// HTTP и (позже) CLI-импорта.
package ingest
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// ErrMalformed — тело не разбирается как JSON ожидаемой формы. Это отказ
// доставки (обрыв, обрезанное тело), о нём отправителю сообщаем ошибкой.
// Незнакомое СОДЕРЖИМОЕ (новая метрика, новая форма точки) сюда не относится:
// такой пакет принимается, сохраняется и доразбирается позже.
var ErrMalformed = errors.New("некорректный формат пакета")
// Meta — что рассказала о себе автоматизация Health Auto Export (свои
// заголовки запроса).
type Meta struct {
AutomationName string
AutomationID string
Aggregation string
Period string
SessionID string
// Headers — весь набор заголовков запроса, уже без секретов (вырезает
// транспорт: только он знает список токенов). Документация HAE называет
// пять заголовков, но что приложение шлёт на самом деле — неизвестно, и
// именно из незадокументированного вышли самые полезные находки.
Headers map[string][]string
}
// Result — итог приёма.
type Result struct {
DeliveryID string
Bytes int64
SHA256 string
RawPath string
}
// Service принимает пакеты: сохраняет тело в архив и учитывает доставку.
type Service struct {
arch *archive.Archive
store *store.Store
log *slog.Logger
}
// New собирает use-case приёма.
func New(arch *archive.Archive, st *store.Store, log *slog.Logger) *Service {
return &Service{arch: arch, store: st, log: log.With("capability", "ingest")}
}
// Accept принимает тело пакета: проверяет форму, кладёт в сырой архив и
// заводит запись о доставке.
//
// Порядок важен: сначала тело оказывается на диске, и только потом появляется
// учётная запись. Обратный порядок дал бы учтённую доставку без данных.
//
// Это единственный логирующий чекпоинт приёма — транспорт исход не логирует.
func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, error) {
if err := checkEnvelope(body); err != nil {
// Некорректный ввод от отправителя — норма жизни, команде разбирать
// нечего: DEBUG, а не ERROR.
s.log.DebugContext(ctx, "delivery rejected", "error", err, "bytes", len(body))
return Result{}, err
}
sum := sha256.Sum256(body)
res := Result{
DeliveryID: ident.NewID(),
Bytes: int64(len(body)),
SHA256: hex.EncodeToString(sum[:]),
}
receivedAt := store.Now()
rawPath, err := s.arch.Write(res.DeliveryID, receivedAt, body)
if err != nil {
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID)
return Result{}, fmt.Errorf("archive body: %w", err)
}
res.RawPath = rawPath
err = s.store.CreateDelivery(ctx, store.Delivery{
ID: res.DeliveryID,
ReceivedAt: receivedAt,
Headers: encodeHeaders(meta.Headers),
AutomationName: meta.AutomationName,
AutomationID: meta.AutomationID,
Aggregation: meta.Aggregation,
Period: meta.Period,
SessionID: meta.SessionID,
Bytes: res.Bytes,
SHA256: res.SHA256,
RawPath: rawPath,
ParseStatus: store.ParsePending,
})
if err != nil {
// Тело уже на диске — данные не потеряны, но учёта нет. Разбор архива
// на следующем шаге проекта такую доставку подберёт.
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID, "raw_path", rawPath)
return Result{}, fmt.Errorf("record delivery: %w", err)
}
s.log.InfoContext(ctx, "delivery accepted",
"delivery_id", res.DeliveryID,
"bytes", res.Bytes,
"raw_path", rawPath,
"automation_name", meta.AutomationName,
"aggregation", meta.Aggregation,
"period", meta.Period)
return res, nil
}
// encodeHeaders сериализует заголовки для хранения. Ключи json.Marshal
// сортирует сам, поэтому запись стабильна и её удобно сравнивать между
// доставками. Сбой сериализации не должен ронять приём: заголовки —
// вспомогательные сведения, а не данные, ради которых всё затевалось.
func encodeHeaders(h map[string][]string) string {
if len(h) == 0 {
return "{}"
}
b, err := json.Marshal(h)
if err != nil {
return "{}"
}
return string(b)
}
// envelope — минимальная форма пакета Health Auto Export.
type envelope struct {
Data json.RawMessage `json:"data"`
}
// checkEnvelope проверяет ровно то, что делает пакет доставленным: это JSON,
// в нём есть объект data. Содержимое data не трогаем — его форму определяет
// Apple, и незнакомая форма не повод отвергать доставку.
func checkEnvelope(body []byte) error {
if len(bytes.TrimSpace(body)) == 0 {
return fmt.Errorf("%w: пустое тело", ErrMalformed)
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причину наружу не раскрываем, она уходит в лог
}
if len(env.Data) == 0 {
return fmt.Errorf("%w: нет объекта data", ErrMalformed)
}
if trimmed := bytes.TrimSpace(env.Data); len(trimmed) == 0 || trimmed[0] != '{' {
return fmt.Errorf("%w: data не объект", ErrMalformed)
}
return nil
}