Initial commit

This commit is contained in:
mats committed 2026-10-19 16:04:17 +02:00
commit afed023152
8152 files changed
+2086461

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
package api
import (
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.k6n.net/mats/fullstack-template/internal/auth"
)
// AuthDeps bundles the OIDC collaborators; nil Auth in Deps disables the
// /api/auth routes and the /api/v1 guard (standalone mode).
type AuthDeps struct {
Client *auth.Client
Signer *auth.Signer
SessionTTL time.Duration // zero means the 24h default
}
const defaultSessionTTL = 24 * time.Hour
type authHandler struct {
client *auth.Client
signer *auth.Signer
sessionTTL time.Duration
logger *slog.Logger
}
// sessionUser is the public shape of the signed-in identity.
type sessionUser struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
// login starts the browser flow: it parks a login transaction (state,
// nonce, PKCE verifier, return path) in a short-lived cookie and redirects
// to the issuer's authorization endpoint.
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
tx, err := auth.NewLoginTx(sanitizeReturnTo(r.URL.Query().Get("returnTo")))
if err != nil {
h.fail(w, err)
return
}
cookie, err := h.signer.NewLoginTxCookie(tx, requestSecure(r))
if err != nil {
h.fail(w, err)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, h.client.AuthCodeURL(tx, redirectURI(r)), http.StatusFound)
}
// callback completes the flow: it checks state, exchanges the code with the
// PKCE verifier, verifies the ID token (signature, iss/aud/exp, nonce), and
// installs the session cookie. Any failure redirects back to the app with
// an auth_error code — the SPA surfaces it; details stay in server logs.
func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
secure := requestSecure(r)
http.SetCookie(w, auth.Expired(auth.CookieLoginTx, secure))
fail := func(code string) {
http.Redirect(w, r, "/?auth_error="+url.QueryEscape(code), http.StatusFound)
}
// An issuer-reported error (user denied, interaction required, …) is
// more useful than our own missing-transaction complaint.
if e := r.URL.Query().Get("error"); e != "" {
fail(e)
return
}
tx, ok := h.readLoginTx(r)
if !ok {
fail("expired_login")
return
}
q := r.URL.Query()
if q.Get("state") != tx.State {
fail("state_mismatch")
return
}
user, err := h.client.CompleteCode(r.Context(), q.Get("code"), redirectURI(r), tx)
if err != nil {
h.logger.Error("oidc code exchange failed", "err", err)
fail("code_exchange")
return
}
cookie, err := h.signer.NewSessionCookie(
auth.Session{Sub: user.Sub, Name: user.Name, Email: user.Email, Roles: user.Roles},
h.sessionTTL, secure)
if err != nil {
h.fail(w, err)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, tx.ReturnTo, http.StatusFound)
}
// session reports who is signed in; it answers 200 either way so the SPA
// can poll it without treating "anonymous" as an error.
func (h *authHandler) session(w http.ResponseWriter, r *http.Request) {
type sessionResponse struct {
Authenticated bool `json:"authenticated"`
User *sessionUser `json:"user"`
}
res := sessionResponse{}
if sess, ok := CurrentSession(h.signer, r); ok {
res.Authenticated = true
res.User = &sessionUser{Sub: sess.Sub, Name: sess.Name, Email: sess.Email, Roles: sess.Roles}
}
writeJSON(w, http.StatusOK, res)
}
// logout clears the session cookie.
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.Expired(auth.CookieSession, requestSecure(r)))
w.WriteHeader(http.StatusNoContent)
}
// readLoginTx loads and validates the login-transaction cookie.
func (h *authHandler) readLoginTx(r *http.Request) (auth.LoginTx, bool) {
cookie, err := r.Cookie(auth.CookieLoginTx)
if err != nil {
return auth.LoginTx{}, false
}
var tx auth.LoginTx
if h.signer.Open(cookie.Value, &tx) != nil || !tx.Valid() {
return auth.LoginTx{}, false
}
return tx, true
}
// CurrentSession returns the valid session behind the request, if any. It
// is the single read path shared by the session endpoint and requireAuth.
func CurrentSession(signer *auth.Signer, r *http.Request) (auth.Session, bool) {
cookie, err := r.Cookie(auth.CookieSession)
if err != nil {
return auth.Session{}, false
}
var sess auth.Session
if signer.Open(cookie.Value, &sess) != nil || !sess.Valid() {
return auth.Session{}, false
}
return sess, true
}
// requireAuth guards the business API behind a valid session cookie. It is
// installed inside the chain, so unknown /api/v1/* routes answer 401, not
// 404 — route existence is not public information.
func requireAuth(signer *auth.Signer) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v1/") {
if _, ok := CurrentSession(signer, r); !ok {
writeError(w, http.StatusUnauthorized, "unauthorized",
"sign in via /api/auth/login to call the API")
return
}
}
next.ServeHTTP(w, r)
})
}
}
// redirectURI rebuilds the public origin the browser used, so the issuer
// sends the user back through the same host that started the flow — the
// vite proxy (localhost or *.preview.k6n.net) or the API itself. The proxy
// sets X-Forwarded-Host/Proto; direct requests fall back to r.Host/TLS.
func redirectURI(r *http.Request) string {
return requestScheme(r) + "://" + requestHost(r) + "/api/auth/callback"
}
// requestSecure reports whether the browser reached us over https.
func requestSecure(r *http.Request) bool { return requestScheme(r) == "https" }
// requestScheme resolves the scheme from X-Forwarded-Proto (first hop) or
// the connection itself.
func requestScheme(r *http.Request) string {
if proto := forwardedValue(r.Header.Get("X-Forwarded-Proto")); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
// requestHost resolves the host from X-Forwarded-Host (first hop) or Host.
func requestHost(r *http.Request) string {
if host := forwardedValue(r.Header.Get("X-Forwarded-Host")); host != "" {
return host
}
return r.Host
}
// forwardedValue returns the first value of a forwarded header, which is
// what our own proxy appended.
func forwardedValue(v string) string {
first, _, _ := strings.Cut(v, ",")
return strings.TrimSpace(first)
}
// sanitizeReturnTo keeps post-login redirects inside the app: relative
// paths only, no protocol-relative //host or scheme tricks.
func sanitizeReturnTo(v string) string {
if v == "" || !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") ||
strings.ContainsAny(v, "\r\n\\") {
return "/"
}
return v
}
// fail maps unexpected internal errors to the standard envelope.
func (h *authHandler) fail(w http.ResponseWriter, err error) {
h.logger.Error("auth handler failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
+400
View File
@@ -0,0 +1,400 @@
package api_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.k6n.net/mats/fullstack-template/internal/api"
"git.k6n.net/mats/fullstack-template/internal/auth"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// fakeIDP is a minimal OpenID Connect issuer: discovery, a token endpoint
// that answers any authorization code with a freshly signed RS256 ID token,
// and the matching JWKS. Enough to drive the real client end to end.
type fakeIDP struct {
server *httptest.Server
key *rsa.PrivateKey
clientID string
// nonceForCode records the nonce embedded in the token issued per code.
nonceForCode map[string]string
}
func newFakeIDP(t *testing.T) *fakeIDP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
idp := &fakeIDP{key: key, clientID: "test-client", nonceForCode: map[string]string{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
writeJSONString(w, map[string]any{
"issuer": idp.server.URL,
"authorization_endpoint": idp.server.URL + "/authorize",
"token_endpoint": idp.server.URL + "/token",
"jwks_uri": idp.server.URL + "/keys",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("GET /keys", func(w http.ResponseWriter, _ *http.Request) {
pub := idp.key.PublicKey
writeJSONString(w, map[string]any{
"keys": []map[string]any{{
"kty": "RSA", "kid": "test-key", "alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}},
})
})
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
claims := map[string]any{
"iss": idp.server.URL,
"sub": "user-1",
"aud": idp.clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"name": "Test User",
"email": "test@example.com",
"roles": []string{"admin"},
}
// The authorize step would have bound the nonce to this code; the
// test injects it via nonceForCode before calling the callback.
if nonce := idp.nonceForCode[code]; nonce != "" {
claims["nonce"] = nonce
}
writeJSONString(w, map[string]any{
"access_token": "at-" + code,
"token_type": "Bearer",
"id_token": idp.sign(t, claims),
})
})
idp.server = httptest.NewServer(mux)
t.Cleanup(idp.server.Close)
return idp
}
// sign produces a compact RS256 JWS for the claims.
func (f *fakeIDP) sign(t *testing.T, claims map[string]any) string {
t.Helper()
header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test-key"})
if err != nil {
t.Fatalf("marshal header: %v", err)
}
payload, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func writeJSONString(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// newAuthTestServer builds the full api stack with OIDC pointed at idp.
func newAuthTestServer(t *testing.T, idp *fakeIDP) http.Handler {
t.Helper()
client, err := auth.New(context.Background(), auth.Config{
Issuer: idp.server.URL,
ClientID: idp.clientID,
ClientSecret: "test-secret",
})
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
Auth: &api.AuthDeps{
Client: client,
Signer: auth.NewSigner("test-signing-secret"),
SessionTTL: time.Hour,
},
})
}
// startLogin drives GET /api/auth/login and returns (tx cookie, parsed
// authorize URL).
func startLogin(t *testing.T, h http.Handler, target string) (*http.Cookie, *url.URL) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = "localhost:5173"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("login = %d: %s", rec.Code, rec.Body.String())
}
var txCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieLoginTx {
txCookie = c
}
}
if txCookie == nil {
t.Fatalf("login set no %s cookie", auth.CookieLoginTx)
}
authURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
return txCookie, authURL
}
func TestOIDCLoginFlow(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
// Anonymous: session reports unauthenticated, /api/v1 is closed.
rec := do(t, h, http.MethodGet, "/api/auth/session", "")
var sess struct {
Authenticated bool `json:"authenticated"`
User any `json:"user"`
}
decodeBody(t, rec, &sess)
if sess.Authenticated || sess.User != nil {
t.Errorf("anonymous session = %+v", sess)
}
if rec = do(t, h, http.MethodGet, "/api/v1/items", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("items without session = %d, want 401", rec.Code)
}
// Login redirect: PKCE + state + nonce present, redirect URI rebuilt
// from the request host.
txCookie, authURL := startLogin(t, h, "/api/auth/login?returnTo=/overview")
q := authURL.Query()
if q.Get("response_type") != "code" || q.Get("client_id") != idp.clientID {
t.Errorf("authorize query = %v", q)
}
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("PKCE missing: %v", q)
}
if q.Get("state") == "" || q.Get("nonce") == "" {
t.Errorf("state/nonce missing: %v", q)
}
if got := q.Get("redirect_uri"); got != "http://localhost:5173/api/auth/callback" {
t.Errorf("redirect_uri = %q", got)
}
// The IdP remembers the nonce for the code it would mint here.
idp.nonceForCode["stub-code"] = q.Get("nonce")
// Callback: state + tx cookie exchange for a session cookie.
req := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=stub-code&state="+url.QueryEscape(q.Get("state")), nil)
req.Host = "localhost:5173"
req.AddCookie(txCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("callback = %d: %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/overview" {
t.Errorf("callback Location = %q, want /overview", loc)
}
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieSession {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("callback set no session cookie")
}
// Signed in: session knows the user, /api/v1 opens up.
req = httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("session = %d", rec.Code)
}
var after struct {
Authenticated bool `json:"authenticated"`
User struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
} `json:"user"`
}
decodeBody(t, rec, &after)
if !after.Authenticated || after.User.Sub != "user-1" ||
after.User.Name != "Test User" || after.User.Email != "test@example.com" {
t.Errorf("session after login = %+v", after)
}
if len(after.User.Roles) != 1 || after.User.Roles[0] != "admin" {
t.Errorf("roles = %v", after.User.Roles)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("items with session = %d: %s", rec.Code, rec.Body.String())
}
// Logout clears the session; /api/v1 closes again.
req = httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("logout = %d", rec.Code)
}
var cleared *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieSession && c.MaxAge < 0 {
cleared = c
}
}
if cleared == nil {
t.Fatal("logout did not expire the session cookie")
}
}
func TestOIDCCallbackRejectsBadState(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
txCookie, _ := startLogin(t, h, "/api/auth/login")
req := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=c&state=wrong", nil)
req.AddCookie(txCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=state_mismatch" {
t.Fatalf("callback with wrong state = %d %q", rec.Code, rec.Header().Get("Location"))
}
// And without any tx cookie at all.
rec = do(t, h, http.MethodGet, "/api/auth/callback?code=c&state=x", "")
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=expired_login" {
t.Fatalf("callback without tx cookie = %d %q", rec.Code, rec.Header().Get("Location"))
}
}
func TestOIDCCallbackPassesIssuerErrorThrough(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
rec := do(t, h, http.MethodGet, "/api/auth/callback?error=access_denied", "")
if rec.Code != http.StatusFound ||
rec.Header().Get("Location") != "/?auth_error=access_denied" {
t.Fatalf("callback issuer error = %d %q", rec.Code, rec.Header().Get("Location"))
}
}
func TestAuthRoutesAbsentInStandaloneMode(t *testing.T) {
h := api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
})
if rec := do(t, h, http.MethodGet, "/api/auth/session", ""); rec.Code != http.StatusNotFound {
t.Errorf("session without OIDC = %d, want 404", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/api/v1/items", ""); rec.Code != http.StatusOK {
t.Errorf("items without OIDC = %d, want 200 (standalone stays open)", rec.Code)
}
}
func TestRequireAuthCoversUnknownV1Routes(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
if rec := do(t, h, http.MethodGet, "/api/v1/nope", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("unknown /api/v1 route = %d, want 401 (not 404)", rec.Code)
}
if !strings.Contains(recBodyFor(t, h, "/api/v1/nope"), "unauthorized") {
t.Error("401 body should carry the unauthorized code")
}
}
func TestForwardedHeadersBuildPreviewRedirectURI(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
req := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
req.Host = "backend.internal:8080"
req.Header.Set("X-Forwarded-Host", "worker-7.preview.k6n.net")
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
authURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
if got := authURL.Query().Get("redirect_uri"); got != "https://worker-7.preview.k6n.net/api/auth/callback" {
t.Errorf("redirect_uri = %q", got)
}
// Session cookies set behind https must carry Secure.
var txCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieLoginTx {
txCookie = c
}
}
if txCookie == nil || !txCookie.Secure {
t.Fatal("tx cookie behind https must be Secure")
}
}
func TestReturnToSanitization(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
// Open-redirect attempts fall back to "/" inside the tx cookie.
for _, raw := range []string{"https://evil.example", "//evil.example", "\r\n/x", "\\evil"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/login?returnTo="+url.QueryEscape(raw), nil)
h.ServeHTTP(rec, req)
var txCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieLoginTx {
txCookie = c
}
}
if txCookie == nil {
t.Fatalf("returnTo=%q: no tx cookie", raw)
}
var tx auth.LoginTx
if err := auth.NewSigner("test-signing-secret").Open(txCookie.Value, &tx); err != nil {
t.Fatalf("open tx cookie: %v", err)
}
if tx.ReturnTo != "/" {
t.Errorf("returnTo=%q sanitized to %q, want /", raw, tx.ReturnTo)
}
}
}
func recBodyFor(t *testing.T, h http.Handler, target string) string {
t.Helper()
return do(t, h, http.MethodGet, target, "").Body.String()
}
+126
View File
@@ -0,0 +1,126 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strconv"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// itemHandler adapts HTTP requests to the items service: parse input, call
// the service, map errors to status codes. No business logic lives here.
type itemHandler struct {
svc *items.Service
logger *slog.Logger
}
func (h *itemHandler) list(w http.ResponseWriter, r *http.Request) {
f := items.Filter{
Status: items.Status(r.URL.Query().Get("status")),
Limit: queryInt(r, "limit", items.DefaultLimit),
Offset: queryInt(r, "offset", 0),
}
result, err := h.svc.List(r.Context(), f)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *itemHandler) create(w http.ResponseWriter, r *http.Request) {
var in items.CreateInput
if !decodeJSON(w, r, &in) {
return
}
item, err := h.svc.Create(r.Context(), in)
if err != nil {
h.fail(w, err)
return
}
w.Header().Set("Location", "/api/v1/items/"+strconv.FormatInt(item.ID, 10))
writeJSON(w, http.StatusCreated, item)
}
func (h *itemHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *itemHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var in items.UpdateInput
if !decodeJSON(w, r, &in) {
return
}
item, err := h.svc.Update(r.Context(), id, in)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *itemHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
h.fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// pathID parses the {id} path segment; writes a 400 and returns false on
// input that can't be an id.
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer")
return 0, false
}
return id, true
}
// queryInt reads an int query parameter, falling back when absent or junk.
func queryInt(r *http.Request, key string, fallback int) int {
v := r.URL.Query().Get(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return fallback
}
return n
}
// fail is the single translation point from items errors to the wire:
// ErrNotFound → 404, *ValidationError → 400, anything else → 500 (logged).
func (h *itemHandler) fail(w http.ResponseWriter, err error) {
var vErr *items.ValidationError
switch {
case errors.Is(err, items.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "item not found")
case errors.As(err, &vErr):
writeError(w, http.StatusBadRequest, "validation_failed", vErr.Error())
default:
h.logger.Error("request failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
}
+107
View File
@@ -0,0 +1,107 @@
package api
import (
"log/slog"
"net/http"
"runtime/debug"
"time"
)
// middleware wraps an http.Handler. Keep middleware transport-level;
// business concerns belong in services.
type middleware func(http.Handler) http.Handler
// chain composes middlewares so chain(h, a, b) == a(b(h)): the first
// argument runs outermost.
func chain(h http.Handler, mws ...middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// logging records one line per request after it completes.
func logging(logger *slog.Logger) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
// recoverPanics turns panics into 500s instead of dropped connections.
func recoverPanics(logger *slog.Logger) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
logger.Error("panic recovered", "panic", v, "stack", string(debug.Stack()))
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
}()
next.ServeHTTP(w, r)
})
}
}
// cors lets the configured origins call the API from a browser (e.g. the
// Vite dev server on :5173). A "*" entry allows every origin.
func cors(allowed []string) middleware {
set := make(map[string]struct{}, len(allowed))
allowAll := false
for _, o := range allowed {
if o == "*" {
allowAll = true
}
set[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if _, ok := set[origin]; origin != "" && (allowAll || ok) {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
h.Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// timeout bounds each request's handler time. The body is the raw timeout
// response — http.TimeoutHandler does not set a content type for it.
func timeout(d time.Duration) middleware {
return func(next http.Handler) http.Handler {
return http.TimeoutHandler(next, d,
`{"error":{"code":"timeout","message":"request timed out"}}`)
}
}
// statusRecorder captures the response status for logging.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Unwrap lets http.ResponseController reach the underlying writer.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
+53
View File
@@ -0,0 +1,53 @@
package api
import (
"encoding/json"
"net/http"
)
// Error responses always use this envelope:
//
// {"error": {"code": "...", "message": "..."}}
//
// Known codes: invalid_json, validation_failed, not_found, invalid_id,
// method_not_allowed, internal_error, timeout, unavailable. Add new codes
// here when needed.
type errorResponse struct {
Error errorDetail `json:"error"`
}
type errorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
// maxBodySize caps JSON request bodies at 1 MiB.
const maxBodySize = 1 << 20
// writeJSON sets the content type and encodes v.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
// The status line is already sent, so an encode failure can only be
// logged — and only by wrapping the ResponseWriter. Not worth it here.
_ = json.NewEncoder(w).Encode(v)
}
// writeError emits the error envelope with the given status and code.
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: errorDetail{Code: code, Message: message}})
}
// decodeJSON strictly decodes the request body into dst: unknown fields and
// bodies over maxBodySize are rejected. It writes the 400 response itself
// and returns false, telling the handler to stop.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON: "+err.Error())
return false
}
return true
}
+151
View File
@@ -0,0 +1,151 @@
// Package api wires HTTP routing, middleware, and JSON conventions on top
// of the stdlib ServeMux (Go 1.22+ method patterns and r.PathValue).
package api
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// Pinger abstracts the database health check so tests can stub it.
type Pinger interface {
PingContext(ctx context.Context) error
}
// Deps are the server's runtime collaborators, wired in cmd/api.
type Deps struct {
Logger *slog.Logger
Items *items.Service
DB Pinger
AllowedOrigins []string
RequestTimeout time.Duration // zero means the 30s default
Auth *AuthDeps // nil disables OIDC: /api/auth answers 404, /api/v1 stays open
}
const defaultRequestTimeout = 30 * time.Second
// New builds the full handler: middleware chain around the route table.
// Middleware order (outermost first): logging → recover → cors → timeout
// → (requireAuth, only when OIDC is configured).
func New(deps Deps) http.Handler {
if deps.RequestTimeout <= 0 {
deps.RequestTimeout = defaultRequestTimeout
}
var authH *authHandler
if deps.Auth != nil {
if deps.Auth.SessionTTL <= 0 {
deps.Auth.SessionTTL = defaultSessionTTL
}
authH = &authHandler{
client: deps.Auth.Client,
signer: deps.Auth.Signer,
sessionTTL: deps.Auth.SessionTTL,
logger: deps.Logger,
}
}
s := &server{
logger: deps.Logger,
db: deps.DB,
items: &itemHandler{svc: deps.Items, logger: deps.Logger},
auth: authH,
}
mux := http.NewServeMux()
// Liveness is "the process is up"; readiness is "it can serve real
// traffic", here meaning the database answers.
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.readiness)
if s.auth != nil {
mux.HandleFunc("GET /api/auth/session", s.auth.session)
mux.HandleFunc("GET /api/auth/login", s.auth.login)
mux.HandleFunc("GET /api/auth/callback", s.auth.callback)
mux.HandleFunc("POST /api/auth/logout", s.auth.logout)
}
// Reference CRUD resource. Method-specific patterns make the mux
// answer 405 with an Allow header for free; read segments with
// r.PathValue("id").
mux.HandleFunc("GET /api/v1/items", s.items.list)
mux.HandleFunc("POST /api/v1/items", s.items.create)
mux.HandleFunc("GET /api/v1/items/{id}", s.items.get)
mux.HandleFunc("PATCH /api/v1/items/{id}", s.items.update)
mux.HandleFunc("DELETE /api/v1/items/{id}", s.items.delete)
// The wrapper keeps the mux's automatic 405 + Allow responses for
// wrong-method requests and renders all unmatched routes as JSON 404s
// (the mux default is plain text).
mws := []middleware{
logging(deps.Logger),
recoverPanics(deps.Logger),
cors(deps.AllowedOrigins),
timeout(deps.RequestTimeout),
}
if s.auth != nil {
mws = append(mws, requireAuth(deps.Auth.Signer))
}
return chain(jsonNotFounds(mux), mws...)
}
type server struct {
logger *slog.Logger
db Pinger
items *itemHandler
auth *authHandler
}
// jsonNotFounds delegates to mux and turns its default plain-text responses
// for unmatched requests into the JSON error envelope. A registered "/" or
// catch-all route would swallow the mux's automatic 405s, so this checks
// mux.Handler instead: an empty pattern means nothing matched, and probing
// the registered methods distinguishes "wrong method" (405 + Allow) from
// "unknown route" (404). The probe runs only on the miss path.
func jsonNotFounds(mux *http.ServeMux) http.Handler {
methods := []string{
http.MethodGet, http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete,
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := mux.Handler(r); pattern == "" {
var allowed []string
for _, m := range methods {
probe := r.Clone(r.Context())
probe.Method = m
if _, pattern := mux.Handler(probe); pattern != "" {
allowed = append(allowed, m)
}
}
if len(allowed) > 0 {
w.Header().Set("Allow", strings.Join(allowed, ", "))
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
r.Method+" is not allowed for this route")
return
}
writeError(w, http.StatusNotFound, "not_found", "route not found")
return
}
mux.ServeHTTP(w, r)
})
}
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *server) readiness(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "database is not reachable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
+295
View File
@@ -0,0 +1,295 @@
package api_test
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"git.k6n.net/mats/fullstack-template/internal/api"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// These tests run the full HTTP stack (mux patterns, middleware, JSON
// conventions) against the real items service over an in-memory repository.
// fakeRepo is the same in-memory fake as internal/items/service_test.go,
// duplicated on purpose: test fakes stay package-local.
type fakeRepo struct {
mu sync.Mutex
nextID int64
byID map[int64]items.Item
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{nextID: 1, byID: map[int64]items.Item{}}
}
func (f *fakeRepo) List(_ context.Context, fl items.Filter) ([]items.Item, error) {
f.mu.Lock()
defer f.mu.Unlock()
ids := make([]int64, 0, len(f.byID))
for id := range f.byID {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
out := []items.Item{}
for _, id := range ids {
item := f.byID[id]
if fl.Status != "" && item.Status != fl.Status {
continue
}
out = append(out, item)
}
if fl.Offset < len(out) {
out = out[fl.Offset:]
} else {
out = []items.Item{}
}
if fl.Limit < len(out) {
out = out[:fl.Limit]
}
return out, nil
}
func (f *fakeRepo) Get(_ context.Context, id int64) (items.Item, error) {
f.mu.Lock()
defer f.mu.Unlock()
item, ok := f.byID[id]
if !ok {
return items.Item{}, items.ErrNotFound
}
return item, nil
}
func (f *fakeRepo) Create(_ context.Context, item *items.Item) error {
f.mu.Lock()
defer f.mu.Unlock()
item.ID = f.nextID
f.nextID++
now := time.Now()
item.CreatedAt, item.UpdatedAt = now, now
f.byID[item.ID] = *item
return nil
}
func (f *fakeRepo) Update(_ context.Context, item *items.Item) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byID[item.ID]; !ok {
return items.ErrNotFound
}
item.UpdatedAt = time.Now()
f.byID[item.ID] = *item
return nil
}
func (f *fakeRepo) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byID[id]; !ok {
return items.ErrNotFound
}
delete(f.byID, id)
return nil
}
// stubPinger fakes the database for /readyz.
type stubPinger struct{ err error }
func (p stubPinger) PingContext(context.Context) error { return p.err }
func newTestServer(t *testing.T) http.Handler {
t.Helper()
return api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
})
}
func do(t *testing.T, h http.Handler, method, target, body string) *httptest.ResponseRecorder {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, rdr)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
t.Helper()
if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {
t.Fatalf("decode response body %q: %v", rec.Body.String(), err)
}
}
func TestHealthAndReadiness(t *testing.T) {
h := newTestServer(t)
if rec := do(t, h, http.MethodGet, "/healthz", ""); rec.Code != http.StatusOK {
t.Errorf("healthz = %d, want 200", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/readyz", ""); rec.Code != http.StatusOK {
t.Errorf("readyz = %d, want 200", rec.Code)
}
sick := api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{err: errors.New("down")},
})
if rec := do(t, sick, http.MethodGet, "/readyz", ""); rec.Code != http.StatusServiceUnavailable {
t.Errorf("readyz with dead db = %d, want 503", rec.Code)
}
}
func TestItemCRUDFlow(t *testing.T) {
h := newTestServer(t)
// Create.
rec := do(t, h, http.MethodPost, "/api/v1/items", `{"title":"First","description":"hello"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create = %d: %s", rec.Code, rec.Body.String())
}
var created items.Item
decodeBody(t, rec, &created)
if created.ID != 1 || created.Title != "First" || created.Status != items.StatusActive {
t.Errorf("created = %+v", created)
}
if loc := rec.Header().Get("Location"); loc != "/api/v1/items/1" {
t.Errorf("Location = %q, want /api/v1/items/1", loc)
}
// Get by id (PathValue routing).
rec = do(t, h, http.MethodGet, "/api/v1/items/1", "")
if rec.Code != http.StatusOK {
t.Fatalf("get = %d: %s", rec.Code, rec.Body.String())
}
var got items.Item
decodeBody(t, rec, &got)
if got.Title != "First" {
t.Errorf("get title = %q", got.Title)
}
// Patch one field.
rec = do(t, h, http.MethodPatch, "/api/v1/items/1", `{"status":"archived"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch = %d: %s", rec.Code, rec.Body.String())
}
var patched items.Item
decodeBody(t, rec, &patched)
if patched.Status != items.StatusArchived || patched.Title != "First" {
t.Errorf("patched = %+v", patched)
}
// List.
rec = do(t, h, http.MethodGet, "/api/v1/items", "")
if rec.Code != http.StatusOK {
t.Fatalf("list = %d: %s", rec.Code, rec.Body.String())
}
var listed []items.Item
decodeBody(t, rec, &listed)
if len(listed) != 1 {
t.Fatalf("list len = %d, want 1", len(listed))
}
// Delete, then confirm it's gone.
if rec := do(t, h, http.MethodDelete, "/api/v1/items/1", ""); rec.Code != http.StatusNoContent {
t.Fatalf("delete = %d, want 204", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/api/v1/items/1", ""); rec.Code != http.StatusNotFound {
t.Errorf("get after delete = %d, want 404", rec.Code)
}
}
func TestErrorResponses(t *testing.T) {
h := newTestServer(t)
do(t, h, http.MethodPost, "/api/v1/items", `{"title":"seed"}`)
tests := []struct {
name string
method string
target string
body string
wantStatus int
wantCode string
}{
{"invalid path id", "GET", "/api/v1/items/abc", "", 400, "invalid_id"},
{"unknown item", "GET", "/api/v1/items/999", "", 404, "not_found"},
{"broken json", "POST", "/api/v1/items", `{"title":`, 400, "invalid_json"},
{"unknown field", "POST", "/api/v1/items", `{"title":"x","wat":1}`, 400, "invalid_json"},
{"validation", "POST", "/api/v1/items", `{"title":""}`, 400, "validation_failed"},
{"unknown route", "GET", "/api/v1/nope", "", 404, "not_found"},
{"empty patch", "PATCH", "/api/v1/items/1", `{}`, 400, "validation_failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := do(t, h, tt.method, tt.target, tt.body)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, tt.wantStatus, rec.Body.String())
}
var res struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
decodeBody(t, rec, &res)
if res.Error.Code != tt.wantCode {
t.Errorf("code = %q, want %q", res.Error.Code, tt.wantCode)
}
})
}
}
func TestMethodNotAllowed(t *testing.T) {
h := newTestServer(t)
rec := do(t, h, http.MethodPut, "/api/v1/items/1", `{}`)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("put = %d, want 405 (ServeMux method patterns)", rec.Code)
}
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "PATCH") {
t.Errorf("Allow = %q, want it to mention PATCH", allow)
}
}
func TestCORSPreflight(t *testing.T) {
h := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Errorf("Allow-Origin = %q", got)
}
// Unknown origins get no CORS headers and fall through to the mux.
req = httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
req.Header.Set("Origin", "http://evil.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin for unknown origin = %q, want none", got)
}
}