From 0529a054eb6222ef1c75d3d259d7898260c112c7 Mon Sep 17 00:00:00 2001 From: Aaro Koinsaari <89689072+koinsaari@users.noreply.github.com> Date: Sat, 30 May 2026 12:05:01 +0300 Subject: [PATCH] feat: wire main with config, db, auth, jellyfin adapter Flatten db.Open to return *sql.DB directly (the DB wrapper struct had no methods and no second consumer). Add jellyfin.AsAuthAdapter to translate between the clients and auth packages without an import cycle. Replace the placeholder main.go with full wiring: config load, db open, Jellyfin client, auth service, HTTP server, graceful shutdown. Delete the toy healthz test superseded by internal/http tests. Co-Authored-By: Claude Sonnet 4.6 --- cmd/api-proxy/main.go | 74 ++++++++++---- cmd/api-proxy/main_test.go | 16 --- internal/auth/lockout_test.go | 5 +- internal/auth/login_test.go | 2 +- internal/clients/jellyfin/adapter.go | 53 ++++++++++ internal/clients/jellyfin/adapter_test.go | 116 ++++++++++++++++++++++ internal/db/db.go | 8 +- 7 files changed, 229 insertions(+), 45 deletions(-) delete mode 100644 cmd/api-proxy/main_test.go create mode 100644 internal/clients/jellyfin/adapter.go create mode 100644 internal/clients/jellyfin/adapter_test.go diff --git a/cmd/api-proxy/main.go b/cmd/api-proxy/main.go index 3ede56b..db7ce5a 100644 --- a/cmd/api-proxy/main.go +++ b/cmd/api-proxy/main.go @@ -1,33 +1,67 @@ package main import ( + "context" "log/slog" - "net/http" + stdhttp "net/http" "os" + "os/signal" + "syscall" "time" -) -func healthz(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"ok"}`)) -} + "github.com/Stoganet/api-proxy/internal/auth" + "github.com/Stoganet/api-proxy/internal/clients/jellyfin" + "github.com/Stoganet/api-proxy/internal/config" + "github.com/Stoganet/api-proxy/internal/db" + apihttp "github.com/Stoganet/api-proxy/internal/http" +) func main() { - mux := http.NewServeMux() - mux.HandleFunc("/healthz", healthz) - addr := os.Getenv("LISTEN_ADDR") - if addr == "" { - addr = ":8080" - } logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - logger.Info("api-proxy starting", "addr", addr) - srv := &http.Server{ - Addr: addr, - Handler: mux, - ReadHeaderTimeout: 10 * time.Second, + + cfg, err := config.LoadFromEnv() + if err != nil { + logger.Error("config", "err", err) + os.Exit(2) } - if err := srv.ListenAndServe(); err != nil { - logger.Error("server exited", "err", err) - os.Exit(1) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + database, err := db.Open(ctx, cfg.DBPath) + if err != nil { + logger.Error("db open", "err", err) + os.Exit(2) + } + defer database.Close() + + jfClient := jellyfin.New(cfg.JellyfinURL, cfg.JellyfinAPIKey) + authSvc := auth.NewService(auth.Options{ + DB: database, + Jellyfin: jellyfin.AsAuthAdapter(jfClient), + SignKey: cfg.JWTSigningKey, + }) + + srv := apihttp.NewServer(authSvc, logger) + httpSrv := &stdhttp.Server{ + Addr: cfg.ListenAddr, + Handler: srv, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + logger.Info("api-proxy listening", "addr", cfg.ListenAddr) + if err := httpSrv.ListenAndServe(); err != nil && err != stdhttp.ErrServerClosed { + logger.Error("listen", "err", err) + cancel() + } + }() + + <-ctx.Done() + logger.Info("shutting down") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := httpSrv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown", "err", err) } } diff --git a/cmd/api-proxy/main_test.go b/cmd/api-proxy/main_test.go deleted file mode 100644 index e12f3ad..0000000 --- a/cmd/api-proxy/main_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestHealthzReturns200(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - w := httptest.NewRecorder() - healthz(w, req) - if w.Code != http.StatusOK { - t.Fatalf("got %d, want 200", w.Code) - } -} diff --git a/internal/auth/lockout_test.go b/internal/auth/lockout_test.go index 14b6616..b63796f 100644 --- a/internal/auth/lockout_test.go +++ b/internal/auth/lockout_test.go @@ -2,13 +2,14 @@ package auth import ( "context" + "database/sql" "testing" "time" "github.com/Stoganet/api-proxy/internal/db" ) -func openTestDB(t *testing.T) *db.DB { +func openTestDB(t *testing.T) *sql.DB { t.Helper() d, err := db.Open(context.Background(), ":memory:") if err != nil { @@ -22,7 +23,7 @@ func newLockoutSvc(t *testing.T, now time.Time) *Service { t.Helper() d := openTestDB(t) return NewService(Options{ - DB: d.DB, + DB: d, SignKey: []byte("01234567890123456789012345678901"), Clock: func() time.Time { return now }, }) diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go index ee5d7fb..8ac3dd4 100644 --- a/internal/auth/login_test.go +++ b/internal/auth/login_test.go @@ -26,7 +26,7 @@ func newLoginSvc(t *testing.T, jf JellyfinAuthenticator) *Service { t.Helper() d := openTestDB(t) return NewService(Options{ - DB: d.DB, + DB: d, Jellyfin: jf, SignKey: []byte("01234567890123456789012345678901"), Clock: func() time.Time { return time.Unix(1_700_000_000, 0) }, diff --git a/internal/clients/jellyfin/adapter.go b/internal/clients/jellyfin/adapter.go new file mode 100644 index 0000000..31161f9 --- /dev/null +++ b/internal/clients/jellyfin/adapter.go @@ -0,0 +1,53 @@ +package jellyfin + +import ( + "context" + "errors" + + "github.com/Stoganet/api-proxy/internal/auth" +) + +var _ auth.JellyfinAuthenticator = (*authAdapter)(nil) + +type authAdapter struct{ c *Client } + +// AsAuthAdapter wraps a *Client so it can be passed to auth.NewService. +// Translates jellyfin.Err* sentinels into auth.Err*. +func AsAuthAdapter(c *Client) auth.JellyfinAuthenticator { return &authAdapter{c} } + +func (a *authAdapter) AuthenticateByName(ctx context.Context, u, p string) (*auth.JFAuthResult, error) { + r, err := a.c.AuthenticateByName(ctx, u, p) + if err != nil { + return nil, translateErr(err) + } + return &auth.JFAuthResult{AccessToken: r.AccessToken, UserID: r.UserID, Username: r.Username}, nil +} + +func (a *authAdapter) QuickConnectInitiate(ctx context.Context) (*auth.JFQuickConnectInit, error) { + r, err := a.c.QuickConnectInitiate(ctx) + if err != nil { + return nil, translateErr(err) + } + return &auth.JFQuickConnectInit{Secret: r.Secret, Code: r.Code}, nil +} + +func (a *authAdapter) QuickConnectAuthenticate(ctx context.Context, secret string) (*auth.JFAuthResult, error) { + r, err := a.c.QuickConnectAuthenticate(ctx, secret) + if err != nil { + return nil, translateErr(err) + } + return &auth.JFAuthResult{AccessToken: r.AccessToken, UserID: r.UserID, Username: r.Username}, nil +} + +func translateErr(err error) error { + switch { + case errors.Is(err, ErrInvalidCredentials): + return auth.ErrInvalidCredentials + case errors.Is(err, ErrUpstreamUnavailable): + return auth.ErrJellyfinUnavailable + case errors.Is(err, ErrQuickConnectPending): + return auth.ErrQuickConnectPending + default: + return err + } +} diff --git a/internal/clients/jellyfin/adapter_test.go b/internal/clients/jellyfin/adapter_test.go new file mode 100644 index 0000000..e34580e --- /dev/null +++ b/internal/clients/jellyfin/adapter_test.go @@ -0,0 +1,116 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Stoganet/api-proxy/internal/auth" +) + +func newAdapterServer(t *testing.T, handler http.HandlerFunc) auth.JellyfinAuthenticator { + t.Helper() + s := httptest.NewServer(handler) + t.Cleanup(s.Close) + return AsAuthAdapter(New(s.URL, "")) +} + +func TestAdapter_AuthenticateByName_TranslatesResult(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "AccessToken": "tok", + "User": map[string]any{"Id": "jf-1", "Name": "alice"}, + }) + }) + res, err := a.AuthenticateByName(context.Background(), "alice", "pw") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.AccessToken != "tok" || res.UserID != "jf-1" || res.Username != "alice" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestAdapter_AuthenticateByName_TranslatesInvalidCredentials(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "", http.StatusUnauthorized) + }) + _, err := a.AuthenticateByName(context.Background(), "alice", "wrong") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("want auth.ErrInvalidCredentials, got %v", err) + } +} + +func TestAdapter_AuthenticateByName_TranslatesUpstreamUnavailable(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "", http.StatusInternalServerError) + }) + _, err := a.AuthenticateByName(context.Background(), "alice", "pw") + if !errors.Is(err, auth.ErrJellyfinUnavailable) { + t.Fatalf("want auth.ErrJellyfinUnavailable, got %v", err) + } +} + +func TestAdapter_QuickConnectInitiate_TranslatesResult(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/QuickConnect/Initiate" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"Secret": "s", "Code": "C"}) + }) + res, err := a.QuickConnectInitiate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Secret != "s" || res.Code != "C" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestAdapter_QuickConnectInitiate_TranslatesUpstreamUnavailable(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "", http.StatusInternalServerError) + }) + _, err := a.QuickConnectInitiate(context.Background()) + if !errors.Is(err, auth.ErrJellyfinUnavailable) { + t.Fatalf("want auth.ErrJellyfinUnavailable, got %v", err) + } +} + +func TestAdapter_QuickConnectAuthenticate_TranslatesPending(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/QuickConnect/Authenticate" { + http.Error(w, "", http.StatusUnauthorized) + return + } + http.NotFound(w, r) + }) + _, err := a.QuickConnectAuthenticate(context.Background(), "secret") + if !errors.Is(err, auth.ErrQuickConnectPending) { + t.Fatalf("want auth.ErrQuickConnectPending, got %v", err) + } +} + +func TestAdapter_QuickConnectAuthenticate_TranslatesResult(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/QuickConnect/Authenticate" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "AccessToken": "tok-qc", + "User": map[string]any{"Id": "jf-2", "Name": "bob"}, + }) + return + } + http.NotFound(w, r) + }) + res, err := a.QuickConnectAuthenticate(context.Background(), "secret") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.AccessToken != "tok-qc" || res.UserID != "jf-2" || res.Username != "bob" { + t.Fatalf("unexpected result: %+v", res) + } +} diff --git a/internal/db/db.go b/internal/db/db.go index b105e9e..c0ffd0d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -12,11 +12,7 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS -type DB struct { - *sql.DB -} - -func Open(ctx context.Context, path string) (*DB, error) { +func Open(ctx context.Context, path string) (*sql.DB, error) { dsn := path + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" sqldb, err := sql.Open("sqlite", dsn) if err != nil { @@ -28,7 +24,7 @@ func Open(ctx context.Context, path string) (*DB, error) { if err := applyMigrations(ctx, sqldb); err != nil { return nil, err } - return &DB{sqldb}, nil + return sqldb, nil } func applyMigrations(ctx context.Context, sqldb *sql.DB) error {