Реализация, фаза 1: добавление данных в qbittorrent
This commit is contained in:
+287
-6
@@ -1,33 +1,314 @@
|
||||
// Package httpapi предоставляет HTTP API и веб-UI (server-rendered + htmx).
|
||||
// Package httpapi предоставляет HTTP API и веб-UI (server-rendered).
|
||||
//
|
||||
// Сейчас — каркас: только /healthz. Эндпоинты приёма и ревью — в Ф1+.
|
||||
// Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) —
|
||||
// в worker, чтение — в store. В v1 без авторизации (доверенная LAN).
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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
|
||||
}
|
||||
|
||||
type server struct {
|
||||
deps Deps
|
||||
index *template.Template
|
||||
}
|
||||
|
||||
// NewRouter собирает HTTP-обработчик сервиса.
|
||||
func NewRouter(logger *slog.Logger) http.Handler {
|
||||
func NewRouter(d Deps) (http.Handler, error) {
|
||||
index, err := template.ParseFS(web.FS, "templates/index.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &server{deps: d, index: index}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(requestLogger(logger))
|
||||
r.Use(requestLogger(d.Logger))
|
||||
|
||||
r.Get("/healthz", handleHealthz)
|
||||
|
||||
return r
|
||||
// Веб-UI.
|
||||
r.Get("/", s.handleIndex)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func errJSON(err error) map[string]string {
|
||||
return map[string]string{"error": err.Error()}
|
||||
}
|
||||
|
||||
// requestLogger пишет структурированный лог по каждому запросу.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
type fakeIngestor struct {
|
||||
res ingest.Result
|
||||
err error
|
||||
lastReq ingest.Request
|
||||
}
|
||||
|
||||
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
|
||||
f.lastReq = req
|
||||
return f.res, f.err
|
||||
}
|
||||
|
||||
type fakeCommander struct {
|
||||
cancelled []int64
|
||||
retried []int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.cancelled = append(f.cancelled, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.retried = append(f.retried, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeReader struct {
|
||||
list []store.Download
|
||||
get *store.Download
|
||||
}
|
||||
|
||||
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
|
||||
|
||||
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
if f.get != nil {
|
||||
return f.get, nil
|
||||
}
|
||||
return &store.Download{ID: id, State: store.StateCancelled}, nil
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||
t.Helper()
|
||||
if d.Logger == nil {
|
||||
d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
h, err := httpapi.NewRouter(d)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestAPIAdd(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: 1, Infohash: "abc", State: store.StateDownloading}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
||||
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc","context":"Дюна"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if got["id"].(float64) != 1 || got["state"] != "downloading" {
|
||||
t.Errorf("body = %v", got)
|
||||
}
|
||||
if ing.lastReq.Context != "Дюна" {
|
||||
t.Errorf("контекст не проброшен: %q", ing.lastReq.Context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAddBadInput(t *testing.T) {
|
||||
ing := &fakeIngestor{err: ingestErr("bad magnet")}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIList(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: 2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohash: store.NullString("abc")},
|
||||
{ID: 1, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/api/downloads")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var got []map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0]["state"] != "completed" || got[0]["infohash"] != "abc" {
|
||||
t.Errorf("first = %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICancel(t *testing.T) {
|
||||
cmd := &fakeCommander{}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != 5 {
|
||||
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRenders(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
||||
}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), "jellybit") || !strings.Contains(string(body), "downloading") {
|
||||
t.Error("страница не содержит ожидаемого контента")
|
||||
}
|
||||
}
|
||||
|
||||
type ingestErr string
|
||||
|
||||
func (e ingestErr) Error() string { return string(e) }
|
||||
Reference in New Issue
Block a user