phase 0 complete

This commit is contained in:
2026-08-14 11:29:11 -06:00
parent 4da16c7573
commit 85df9a208e
102 changed files with 25019 additions and 30 deletions
+75
View File
@@ -0,0 +1,75 @@
package httpserver
import (
"context"
"encoding/json"
"net/http"
"time"
"solopm.com/solopm-server/internal/config"
)
// DBPinger is the subset of *pgxpool.Pool used for health checks.
type DBPinger interface {
Ping(ctx context.Context) error
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
Database string `json:"database"`
Meilisearch string `json:"meilisearch"`
SMTP string `json:"smtp"`
}
func healthzHandler(cfg *config.Config, db DBPinger, version string) http.HandlerFunc {
httpClient := &http.Client{Timeout: 2 * time.Second}
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
resp := healthResponse{
Status: "ok",
Version: version,
Meilisearch: "absent",
SMTP: "absent",
}
if err := db.Ping(ctx); err != nil {
resp.Status = "error"
resp.Database = "unreachable"
} else {
resp.Database = "ok"
}
if cfg.SearchEnabled() {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.MeiliURL+"/health", nil)
if err == nil {
if hr, err := httpClient.Do(req); err == nil {
_ = hr.Body.Close()
if hr.StatusCode == http.StatusOK {
resp.Meilisearch = "ok"
} else {
resp.Meilisearch = "unreachable"
}
} else {
resp.Meilisearch = "unreachable"
}
}
}
if cfg.EmailEnabled() {
resp.SMTP = "ok"
}
status := http.StatusOK
if resp.Status != "ok" {
status = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(resp)
}
}
+59
View File
@@ -0,0 +1,59 @@
package httpserver
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"solopm.com/solopm-server/internal/config"
)
type stubPinger struct{ err error }
func (s stubPinger) Ping(ctx context.Context) error { return s.err }
func TestHealthzHandler(t *testing.T) {
cfg := &config.Config{} // no MEILI_URL/SMTP_HOST set: both report "absent"
t.Run("db ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
healthzHandler(cfg, stubPinger{}, "test")(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body healthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.Status != "ok" || body.Database != "ok" {
t.Fatalf("body = %+v, want status=ok database=ok", body)
}
if body.Meilisearch != "absent" || body.SMTP != "absent" {
t.Fatalf("body = %+v, want meilisearch/smtp absent", body)
}
})
t.Run("db unreachable", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
healthzHandler(cfg, stubPinger{err: errors.New("boom")}, "test")(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
var body healthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.Status != "error" || body.Database != "unreachable" {
t.Fatalf("body = %+v, want status=error database=unreachable", body)
}
})
}
+30
View File
@@ -0,0 +1,30 @@
package httpserver
import (
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
)
// RequestLogger logs one structured line per request: method, path, status,
// duration, and (once auth exists) user id.
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) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
logger.Info("http_request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
}
+59
View File
@@ -0,0 +1,59 @@
package httpserver
import (
"io/fs"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"solopm.com/solopm-server/internal/config"
)
// Options bundles the dependencies the router needs to wire routes.
type Options struct {
Config *config.Config
Logger *slog.Logger
DB DBPinger
Version string
Dev bool // permissive CORS in dev; same-origin default otherwise
SPA fs.FS // web/dist, rooted so index.html is at the top level; nil disables SPA serving
}
// New builds the chi router with the full middleware chain (minus the auth
// resolver, which is added in Phase 1) and registers Phase 0 routes.
func New(opts Options) chi.Router {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP) //nolint:staticcheck // middleware order is spec-mandated (01-architecture.md); deploy behind a trusted reverse proxy
r.Use(RequestLogger(opts.Logger))
r.Use(middleware.Recoverer)
r.Use(corsMiddleware(opts.Dev))
r.Get("/healthz", healthzHandler(opts.Config, opts.DB, opts.Version))
if opts.SPA != nil {
r.Get("/*", spaHandler(opts.SPA))
}
return r
}
func corsMiddleware(dev bool) func(http.Handler) http.Handler {
if dev {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type", "Authorization"},
AllowCredentials: false,
})
}
// Same-origin default: no CORS headers granted, browser same-origin
// requests work regardless since no cross-origin request needs them.
return cors.Handler(cors.Options{
AllowedOrigins: []string{},
})
}
+37
View File
@@ -0,0 +1,37 @@
package httpserver
import (
"io/fs"
"net/http"
)
// spaHandler serves the embedded Vue SPA out of distFS (already rooted at
// web/dist, i.e. index.html is at its top level). Any request path that
// doesn't match a real file falls back to index.html so client-side routes
// (deep links) resolve correctly.
func spaHandler(distFS fs.FS) http.HandlerFunc {
fileServer := http.FileServer(http.FS(distFS))
return func(w http.ResponseWriter, r *http.Request) {
upath := r.URL.Path
if upath == "" || upath == "/" {
upath = "index.html"
} else {
upath = upath[1:] // strip leading slash for fs.Stat
}
if _, err := fs.Stat(distFS, upath); err != nil {
r = cloneWithPath(r, "/")
}
fileServer.ServeHTTP(w, r)
}
}
// cloneWithPath returns a shallow copy of r with URL.Path replaced, so the
// original request (and its URL) is left untouched.
func cloneWithPath(r *http.Request, path string) *http.Request {
r2 := r.Clone(r.Context())
r2.URL.Path = path
return r2
}
+48
View File
@@ -0,0 +1,48 @@
package httpserver
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
)
func testDistFS() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><div id=app></div>")},
"assets/app.js": {Data: []byte("console.log('app')")},
"favicon.svg": {Data: []byte("<svg></svg>")},
}
}
func TestSPAHandler(t *testing.T) {
cases := []struct {
name string
path string
wantBody string
}{
{"root serves index", "/", "<!doctype html><div id=app></div>"},
{"real file served as-is", "/assets/app.js", "console.log('app')"},
{"real top-level file served as-is", "/favicon.svg", "<svg></svg>"},
{"deep link falls back to index", "/projects/42/board", "<!doctype html><div id=app></div>"},
{"unknown asset path falls back to index", "/assets/missing.js", "<!doctype html><div id=app></div>"},
}
handler := spaHandler(testDistFS())
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != tc.wantBody {
t.Fatalf("body = %q, want %q", got, tc.wantBody)
}
})
}
}