phase 0 complete
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user