package metadata import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "time" "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logging" ) const defaultTimeout = 10 * time.Second // newHTTPClient собирает http.Client с опциональным прокси и таймаутом. func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) { if timeout <= 0 { timeout = defaultTimeout } transport := http.DefaultTransport if proxy != "" { u, err := url.Parse(proxy) if err != nil { return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err) } // Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не // собираем голый — иначе при живом-но-залипшем прокси полагались бы // только на общий Client.Timeout. Он остаётся верхней границей запроса. t := http.DefaultTransport.(*http.Transport).Clone() t.Proxy = http.ProxyURL(u) transport = t } return &http.Client{Timeout: timeout, Transport: transport}, nil } const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа // getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц. // дополнительные заголовки (напр. Authorization). service/operation — поля // ext.* для телеметрии вызова. func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation, rawURL string, headers map[string]string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return fmt.Errorf("metadata: build request: %w", err) } req.Header.Set("Accept", "application/json") for k, v := range headers { req.Header.Set(k, v) } return doJSON(ctx, hc, log, service, operation, req, out) } // postJSON выполняет POST с JSON-телом и декодирует ответ. func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation, rawURL string, body, out any) error { payload, err := json.Marshal(body) if err != nil { return fmt.Errorf("metadata: marshal body: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("metadata: build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") return doJSON(ctx, hc, log, service, operation, req, out) } // doJSON выполняет запрос и декодирует ответ, логируя исход телеметрией ext.* // (логическая operation вместо URL: у TMDB api_key передаётся query-параметром, // его нельзя светить в логах). Логгер берётся из ctx (scoped-логгер загрузки), // при отсутствии — переданный fallback. func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation string, req *http.Request, out any) error { log = logctx.FromOr(ctx, log) call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()} resp, err := hc.Do(req) if err != nil { // Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key — // query-параметр: санитизируем до логирования И до обёртки, чтобы ключ // не утёк ни в лог, ни вверх по цепочке %w. err = logging.SanitizeErr(err) call.Failure(log, err) return fmt.Errorf("metadata: request: %w", err) } defer func() { _ = resp.Body.Close() }() call.Status = resp.StatusCode raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { call.Failure(log, err) return fmt.Errorf("metadata: read body: %w", err) } if resp.StatusCode != http.StatusOK { err := fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw)) call.Failure(log, err) return err } if err := json.Unmarshal(raw, out); err != nil { call.Failure(log, err) return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw)) } call.Success(log) return nil } func snippet(b []byte) string { const max = 200 if len(b) > max { return string(b[:max]) + "…" } return string(b) }