Files

380 lines
11 KiB
Go

// Package httpapi предоставляет HTTP API и веб-UI (server-rendered).
//
// Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) —
// в worker, чтение — в store. В v1 без авторизации (доверенная LAN).
package httpapi
import (
"context"
"encoding/json"
"errors"
"html/template"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/web"
)
// Ingestor принимает загрузку (ingest.Service).
type Ingestor interface {
Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
}
// Commander исполняет команды над задачей (worker.Worker).
type Commander interface {
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
}
// Reader читает задачи (store.Store).
type Reader interface {
ListDownloads(ctx context.Context) ([]store.Download, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error)
}
// Deps — зависимости транспорта.
type Deps struct {
Logger *slog.Logger
Ingestor Ingestor
Commander Commander
Reader Reader
Reviewer Reviewer
}
type server struct {
deps Deps
index *template.Template
review *template.Template
}
// NewRouter собирает HTTP-обработчик сервиса.
func NewRouter(d Deps) (http.Handler, error) {
index, err := template.ParseFS(web.FS, "templates/index.html")
if err != nil {
return nil, err
}
review, err := template.New("review.html").
Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}).
ParseFS(web.FS, "templates/review.html")
if err != nil {
return nil, err
}
s := &server{deps: d, index: index, review: review}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(requestLogger(d.Logger))
r.Get("/healthz", handleHealthz)
// Веб-UI.
r.Get("/", s.handleIndex)
r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
// Веб-UI: ревью раскладки.
r.Get("/review/{id}", s.handleReview)
r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
// REST API.
r.Route("/api", func(r chi.Router) {
r.Get("/downloads", s.handleAPIList)
r.Post("/downloads", s.handleAPIAdd)
r.Get("/downloads/{id}", s.handleAPIGet)
r.Post("/downloads/{id}/cancel", s.handleAPICancel)
r.Post("/downloads/{id}/retry", s.handleAPIRetry)
})
return r, nil
}
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// --- Веб-UI ---
type indexView struct {
Error string
Downloads []downloadView
}
type downloadView struct {
ID int64
Source string
Infohash string
Context string
State string
Error string
Terminal bool
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled — можно перепривязать заново
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
downloads, err := s.deps.Reader.ListDownloads(r.Context())
if err != nil {
s.deps.Logger.Error("list downloads", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
view := indexView{Error: r.URL.Query().Get("err")}
for _, d := range downloads {
view.Downloads = append(view.Downloads, toView(d))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.index.Execute(w, view); err != nil {
s.deps.Logger.Error("render index", "err", err)
}
}
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
redirectErr(w, r, "не удалось разобрать форму")
return
}
_, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
Source: r.PostForm.Get("source"),
Context: r.PostForm.Get("context"),
})
if err != nil {
redirectErr(w, r, err.Error())
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
redirectErr(w, r, err.Error())
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- REST API ---
type downloadDTO struct {
ID int64 `json:"id"`
SourceType string `json:"source_type"`
Infohash string `json:"infohash,omitempty"`
Context string `json:"context,omitempty"`
State string `json:"state"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type addRequest struct {
Source string `json:"source"`
Context string `json:"context"`
}
type addResponse struct {
ID int64 `json:"id"`
Infohash string `json:"infohash"`
State string `json:"state"`
Deduplicated bool `json:"deduplicated"`
}
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
downloads, err := s.deps.Reader.ListDownloads(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, errJSON(err))
return
}
out := make([]downloadDTO, 0, len(downloads))
for _, d := range downloads {
out = append(out, toDTO(d))
}
writeJSON(w, http.StatusOK, out)
}
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err))
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errJSON(err))
return
}
writeJSON(w, http.StatusOK, toDTO(*d))
}
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
var req addRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err))
return
}
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
if err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err))
return
}
status := http.StatusCreated
if res.Deduplicated {
status = http.StatusOK
}
writeJSON(w, status, addResponse{
ID: res.DownloadID,
Infohash: res.Infohash,
State: string(res.State),
Deduplicated: res.Deduplicated,
})
}
func (s *server) handleAPICancel(w http.ResponseWriter, r *http.Request) {
s.apiCommand(w, r, s.deps.Commander.Cancel)
}
func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
s.apiCommand(w, r, s.deps.Commander.Retry)
}
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
id, err := pathID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errJSON(err))
return
}
if err := cmd(r.Context(), id); err != nil {
s.deps.Logger.Warn("api command failed", "path", r.URL.Path, "id", id, "err", err)
writeJSON(w, http.StatusConflict, errJSON(err))
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
return
}
writeJSON(w, http.StatusOK, toDTO(*d))
}
// --- helpers ---
func toDTO(d store.Download) downloadDTO {
return downloadDTO{
ID: d.ID,
SourceType: string(d.SourceType),
Infohash: d.Infohash.String,
Context: d.Context,
State: string(d.State),
ErrorCode: d.ErrorCode.String,
ErrorMsg: d.ErrorMsg.String,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}
func toView(d store.Download) downloadView {
return downloadView{
ID: d.ID,
Source: shorten(d.SourceRef, 64),
Infohash: d.Infohash.String,
Context: d.Context,
State: string(d.State),
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
}
}
func shorten(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func pathID(r *http.Request) (int64, error) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return 0, errors.New("invalid id")
}
return id, nil
}
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/?err="+url.QueryEscape(msg), http.StatusSeeOther)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errJSON(err error) map[string]string {
return map[string]string{"error": err.Error()}
}
// requestLogger пишет структурированный лог по каждому запросу. Частые
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
logger.Log(r.Context(), requestLogLevel(r), "http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
}
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
// API (`/api/...`) остаются на INFO.
func requestLogLevel(r *http.Request) slog.Level {
switch {
case r.URL.Path == "/healthz":
return slog.LevelDebug
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}