feat(07-01): wire embed.FS into NewRouter and RunMigrations into cmd/web/main.go

- backend/internal/web/router.go: staticDir string -> staticFS fs.FS; /healthz uses HealthzHandler(); /readyz registered with ReadyzHandler(pinger); embedded FS served via fs.Sub()
- backend/cmd/web/main.go: import assets "backend"; db.RunMigrations(ctx, pool, assets.Migrations) before router; web.NewRouter now receives assets.Static
- All *_test.go NewRouter call sites updated from "./static" to os.DirFS("./static"); "os" import added where missing
This commit is contained in:
Arthur Belleville 2026-05-15 18:14:33 +02:00
parent 77e37cb21b
commit bdd3cba314
No known key found for this signature in database
7 changed files with 43 additions and 19 deletions

View file

@ -1,7 +1,7 @@
// Command web is the Phase 1 walking-skeleton HTTP server. It loads env, // Command web is the Phase 1 walking-skeleton HTTP server. It loads env,
// builds a slog handler, opens a pgxpool, mounts the chi router, and serves // builds a slog handler, opens a pgxpool, runs goose migrations, mounts the
// /, /healthz, /demo/time, and /static/* with graceful shutdown on // chi router, and serves /, /healthz, /readyz, /demo/time, and /static/* with
// SIGINT/SIGTERM (CONTEXT D-19). // graceful shutdown on SIGINT/SIGTERM (CONTEXT D-19, D-10, DEPLOY-03/04).
// //
// No .env parser lives here — `.env` is exported into the process // No .env parser lives here — `.env` is exported into the process
// environment by `just dev`; production injects real env vars (D-15). // environment by `just dev`; production injects real env vars (D-15).
@ -18,6 +18,7 @@ import (
"syscall" "syscall"
"time" "time"
assets "backend"
"backend/internal/auth" "backend/internal/auth"
"backend/internal/db" "backend/internal/db"
"backend/internal/db/sqlc" "backend/internal/db/sqlc"
@ -68,6 +69,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// D-10: run goose migrations from the embedded FS before constructing the
// router. goose.Up is idempotent — already-applied migrations are skipped.
if err := db.RunMigrations(ctx, pool, assets.Migrations); err != nil {
slog.Error("migrations failed", "err", err)
os.Exit(1)
}
q := sqlc.New(pool) q := sqlc.New(pool)
store := auth.NewStore(q) store := auth.NewStore(q)
secure := env != "development" && env != "dev" secure := env != "development" && env != "dev"
@ -113,7 +121,8 @@ func main() {
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) // D-09: pass the embedded static FS — binary has zero runtime file dependencies.
router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + port, Addr: ":" + port,

View file

@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
csrfKey[i] = byte(i + 1) csrfKey[i] = byte(i + 1)
} }
deps := AuthDeps{Queries: q, Store: store, Secure: false} deps := AuthDeps{Queries: q, Store: store, Secure: false}
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
} }
// extractCSRFToken performs a GET request and extracts the _csrf token from the // extractCSRFToken performs a GET request and extracts the _csrf token from the

View file

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -33,14 +34,14 @@ var testCSRFKey = func() []byte {
// Referer header are accepted. // Referer header are accepted.
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
deps := AuthDeps{Queries: q, Store: store, Secure: false} deps := AuthDeps{Queries: q, Store: store, Secure: false}
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
} }
// newTestRouterWithLimiter builds a router with an injected LimiterStore, // newTestRouterWithLimiter builds a router with an injected LimiterStore,
// enabling rate-limit tests to use a fake clock. // enabling rate-limit tests to use a fake clock.
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
} }
// getCSRFToken performs a GET request to path and extracts the CSRF token // getCSRFToken performs a GET request to path and extracts the CSRF token

View file

@ -18,6 +18,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"strings" "strings"
"testing" "testing"
@ -59,7 +60,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS
tabloDeps := TablosDeps{Queries: q} tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q} taskDeps := TasksDeps{Queries: q}
fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25}
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
} }
// ---- TestFileUpload (FILE-01, FILE-02) ---- // ---- TestFileUpload (FILE-01, FILE-02) ----
@ -164,7 +165,7 @@ func TestFileUploadTooLarge(t *testing.T) {
tabloDeps := TablosDeps{Queries: q} tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q} taskDeps := TasksDeps{Queries: q}
fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1}
router := NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") router := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12") user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12")
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{

View file

@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"strings" "strings"
"testing" "testing"
@ -26,7 +27,7 @@ import (
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false} authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q} tabloDeps := TablosDeps{Queries: q}
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
} }
// loginUser signs up a user and returns the session cookie set after signup. // loginUser signs up a user and returns the session cookie set after signup.

View file

@ -15,6 +15,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"strings" "strings"
"testing" "testing"
@ -29,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false} authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q} tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q} taskDeps := TasksDeps{Queries: q}
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
} }
// ---- TestTasksKanbanRenders (TASK-01) ---- // ---- TestTasksKanbanRenders (TASK-01) ----

View file

@ -2,6 +2,7 @@ package web
import ( import (
"context" "context"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -12,7 +13,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
) )
// Pinger is the contract /healthz uses to probe the data plane. *pgxpool.Pool // Pinger is the contract /readyz uses to probe the data plane. *pgxpool.Pool
// satisfies this interface out of the box, which is why cmd/web passes the // satisfies this interface out of the box, which is why cmd/web passes the
// pool directly to NewRouter (no adapter required). // pool directly to NewRouter (no adapter required).
type Pinger interface { type Pinger interface {
@ -29,10 +30,10 @@ type Pinger interface {
// 5. auth.ResolveSession (reads session cookie, attaches user to context) — D-24 // 5. auth.ResolveSession (reads session cookie, attaches user to context) — D-24
// 6. auth.Mount (gorilla/csrf — MUST come after ResolveSession, before routes) — D-24, Pitfall 7 // 6. auth.Mount (gorilla/csrf — MUST come after ResolveSession, before routes) — D-24, Pitfall 7
// //
// Routes: GET / · GET /healthz · GET /demo/time · GET /static/* // Routes: GET / · GET /healthz (liveness) · GET /readyz (readiness) · GET /demo/time · GET /static/*
// GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup. // GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup.
// staticDir is the on-disk path served at /static/*; path traversal is // staticFS is the embedded FS (or os.DirFS in tests) served at /static/*; the
// blocked by http.Dir's default behavior (T-01-08). // embedded FS pattern blocks path traversal at the http.FS layer (T-01-08).
// //
// deps.Store may be nil during unit tests for Phase 1 routes (those routes // deps.Store may be nil during unit tests for Phase 1 routes (those routes
// never exercise session resolution). ResolveSession guards against nil Store. // never exercise session resolution). ResolveSession guards against nil Store.
@ -44,7 +45,7 @@ type Pinger interface {
// trustedOrigins is an optional list of additional origins for the CSRF // trustedOrigins is an optional list of additional origins for the CSRF
// referer check (used in integration tests to allow localhost requests without // referer check (used in integration tests to allow localhost requests without
// a Referer header). In production, pass no extra args — leave empty. // a Referer header). In production, pass no extra args — leave empty.
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(RequestIDMiddleware) r.Use(RequestIDMiddleware)
r.Use(chimw.RealIP) r.Use(chimw.RealIP)
@ -114,11 +115,21 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD
r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps)) r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps))
}) })
r.Get("/healthz", HealthzHandler(pinger)) // Liveness probe (D-12): always 200, no DB contact.
r.Get("/healthz", HealthzHandler())
// Readiness probe (D-13): probes DB; 200 when ready, 503 when degraded.
r.Get("/readyz", ReadyzHandler(pinger))
r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() })) r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() }))
fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))) // Serve embedded static assets. Sub to strip the "static/" prefix from the
r.Get("/static/*", fs.ServeHTTP) // embedded path so /static/tailwind.css maps to static/tailwind.css inside
// the FS. The "fs" local name is avoided to prevent shadowing the "io/fs" import.
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("router: failed to sub static FS: " + err.Error())
}
fileHandler := http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
r.Get("/static/*", fileHandler.ServeHTTP)
return r return r
} }