/
codespawn
/
tgstream
Обзор
Документация
Войти
/
codespawn
/
tgstream
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
16
CI/CD
Аналитика
Безопасность
master
internal/adapter/httpserver/handler.go
214 строк
9 KB
codespawn
feat: first working version
27 июн 2026, 17:00
27 июн 2026, 17:00
492bb24
Код
Авторство
О чём код?
// Package httpserver — driving-адаптер: HTTP-роутинг, валидация имени, // ETag/304, заголовки кэширования и content negotiation (PROTOCOL.md §B, // AGENTS.md §8). Зависит от use-case (FeedFetcher) и Formatter'а, но НЕ // от конкретных реализаций — тестируется на фейках. package httpserver import ( "context" "errors" "log/slog" "net/http" "strconv" "strings" "time" "gitverse.ru/codespawn/tgstream/internal/domain" "gitverse.ru/codespawn/tgstream/internal/port" "gitverse.ru/codespawn/tgstream/internal/service" ) // FeedFetcher — use-case, оркестрирующий cache-aside/SWR. *service.FeedService // удовлетворяет интерфейсу; в тестах подменяется фейком. Узкий интерфейс здесь // (а не импорт конкретного *FeedService) — Go-идиома «accept interfaces». type FeedFetcher interface { Feed(ctx context.Context, channel string) ([]domain.Post, domain.Channel, service.Status, error) } // NamedFormatter связывает ключ формата ("rss"/"atom"/"json") с реализацией // port.Formatter для content negotiation (PROTOCOL.md §B.6). type NamedFormatter struct { Key string Formatter port.Formatter } // Config настраивает Handler. type Config struct { // Feed — use-case (обязателен). Feed FeedFetcher // Formatters — доступные форматы; [0] — дефолт (MVP: один RSS). // Выбор — по ?format=/суффиксу/Accept (см. selectFormatter). Formatters []NamedFormatter // CacheTTL — max-age для Cache-Control (PROTOCOL.md §B.4). 0 → max-age=0. CacheTTL time.Duration // RetryAfter — значение заголовка Retry-After при 503 (PROTOCOL.md §B.5). RetryAfter time.Duration // Logger — логгер для внутренних ошибок (format error → 500 и т.п.). // nil → slog.Default(). Каждый запрос отдельно логируется access-log // middleware из observe.LogHTTP в composition root. Logger *slog.Logger } // Handler — HTTP-вход tgstream. Реализует http.Handler (делегирует в ServeMux). type Handler struct { mux *http.ServeMux feed FeedFetcher formats formatterRegistry cacheTTL time.Duration retryAfter time.Duration log *slog.Logger } // New собирает Handler и регистрирует маршруты. Требует хотя бы один Formatter // (дефолт); иначе — panic (ошибка программирования в composition root). func New(cfg Config) *Handler { if cfg.Feed == nil { panic("httpserver: nil FeedFetcher") } reg := newFormatterRegistry(cfg.Formatters) log := cfg.Logger if log == nil { log = slog.Default() } h := &Handler{ feed: cfg.Feed, formats: reg, cacheTTL: cfg.CacheTTL, retryAfter: cfg.RetryAfter, log: log, } mux := http.NewServeMux() // Go 1.22+ ServeMux: метод + path-wildcard {channel}. mux.HandleFunc("GET /feed/{channel}", h.handleFeed) mux.HandleFunc("GET /healthz", h.handleHealth) h.mux = mux return h } // ServeHTTP делегирует в зарегистрированные маршруты. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } // handleHealth — liveness-проба (PROTOCOL.md §B.1): 200 {"status":"ok"}. func (h *Handler) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } // handleFeed — GET /feed/{channel}: нормализация → fetch → форматирование → ответ // с ETag/Last-Modified/Cache-Control/X-Channel-Status и условными 304. func (h *Handler) handleFeed(w http.ResponseWriter, r *http.Request) { raw := r.PathValue("channel") // Content negotiation: суффикс пути (/feed/x.atom) вынести ДО валидации имени, // т.к. точка недопустима в каноничном имени и является признаком формата. namePart, fmtKey := splitFormatSuffix(raw) canonical := normalizeName(namePart) // 1. Валидация (PROTOCOL.md §B.3). if err := validateName(canonical); err != nil { writeError(w, http.StatusBadRequest, "invalid channel name") return } // 2. Канонический путь: неканоничный ввод → 301 на /feed/<canonical>. if namePart != canonical { h.log.Log(r.Context(), slog.LevelDebug, "redirect to canonical", "requested", namePart, "canonical", canonical) http.Redirect(w, r, canonicalFeedPath(canonical, fmtKey, r.URL.RawQuery), http.StatusMovedPermanently) return } // 3. Выбор форматера (?format= > суффикс > Accept > дефолт). f := h.formats.selectFormatter(fmtKey, r.URL.Query().Get("format"), r.Header.Get("Accept")) // 4. Оркестрация: cache-aside/SWR в FeedService. posts, ch, status, err := h.feed.Feed(r.Context(), canonical) if err != nil { h.writeFeedError(w, r, canonical, err) return } // 5. Форматирование (до ETag: ETag = хэш тела, чтобы гарантированно совпадать). body, ferr := f.Format(ch, posts) if ferr != nil { h.log.Error("feed format failed", "channel", canonical, "error", ferr) writeError(w, http.StatusInternalServerError, "format error") return } // 6. Заголовки ответа (PROTOCOL.md §B.2/B.4). w.Header().Set("Content-Type", f.ContentType()+"; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age="+strconv.FormatInt(int64(h.cacheTTL.Seconds()), 10)) w.Header().Set("ETag", etagFromBody(body)) lastMod := lastModified(posts, ch) if !lastMod.IsZero() { w.Header().Set("Last-Modified", lastMod.UTC().Format(http.TimeFormat)) } w.Header().Set("X-Channel-Status", string(status)) // 7. Условный запрос → 304 (пустое тело, заголовки сохранены). if isNotModified(r, etagFromBody(body), lastMod) { h.log.Log(r.Context(), slog.LevelDebug, "not modified", "channel", canonical) w.WriteHeader(http.StatusNotModified) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } // writeFeedError маппит ошибки use-case в HTTP-статусы (PROTOCOL.md §B.5). func (h *Handler) writeFeedError(w http.ResponseWriter, r *http.Request, channel string, err error) { if errors.Is(err, domain.ErrChannelNotFound) { h.log.Log(r.Context(), slog.LevelInfo, "channel not found", "channel", channel) writeError(w, http.StatusNotFound, "channel not found or private") return } // Source unavailable + нет кэша → 503 + Retry-After (SWR с кэшем обрабатывается // в FeedService и сюда не доходит как ошибка). h.log.Log(r.Context(), slog.LevelWarn, "feed source unavailable", "channel", channel, "error", err) w.Header().Set("Retry-After", strconv.FormatInt(int64(h.retryAfter.Seconds()), 10)) writeError(w, http.StatusServiceUnavailable, "source unavailable") } // writeError пишет минималистичный текстовый ответ-ошибку. func writeError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(code) _, _ = w.Write([]byte(msg + "\n")) } // canonicalFeedPath строит целевой путь для 301-редиректа: /feed/<canonical> с // сохранением суффикса формата и query-строки. func canonicalFeedPath(canonical, fmtKey, rawQuery string) string { p := "/feed/" + canonical if fmtKey != "" { p += "." + fmtKey } if rawQuery != "" { p += "?" + rawQuery } return p } // knownSuffixes — поддерживаемые суффиксы content negotiation (PROTOCOL.md §B.1). var knownSuffixes = []string{".rss", ".atom", ".json"} // splitFormatSuffix отделяет суффикс формата от имени канала: // "durov.rss" → ("durov", "rss"); "durov" → ("durov", ""). Точка невозможна в // каноничном имени, поэтому любой известный суффикс трактуется как формат. func splitFormatSuffix(s string) (name, fmtKey string) { for _, ext := range knownSuffixes { if strings.HasSuffix(s, ext) { return s[:len(s)-len(ext)], ext[1:] } } return s, "" }