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:
parent
77e37cb21b
commit
bdd3cba314
7 changed files with 43 additions and 19 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) ----
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue