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)
}
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// Cookie names. The login-tx cookie is scoped to /api/auth so it is only
// sent to the endpoints that need it.
const (
CookieSession = "fs_session"
CookieLoginTx = "fs_auth_tx"
)
// Session is the signed-in identity kept in the session cookie. It is a
// self-contained, HMAC-protected claim set — not a bearer token: it carries
// no issuer credentials and only opens this API.
type Session struct {
Sub string
Name string
Email string
Roles []string
ExpiresAt time.Time
}
// Valid reports whether the session is still within its TTL.
func (s Session) Valid() bool { return time.Now().Before(s.ExpiresAt) }
// ErrTampered is returned when a cookie value fails its HMAC check or is
// malformed — treat it exactly like a missing cookie.
var ErrTampered = errors.New("auth cookie failed integrity check")
// Signer seals values into "<base64url(json)>.<base64url(hmac-sha256)>"
// cookie payloads and opens them back. Construction is cheap; keep one
// per server and share it.
type Signer struct{ key []byte }
// NewSigner returns a Signer keyed by secret.
func NewSigner(secret string) *Signer { return &Signer{key: []byte(secret)} }
// Seal encodes v into a signed cookie value.
func (s *Signer) Seal(v any) (string, error) {
payload, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("marshal cookie payload: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload) + "." + s.mac(payload), nil
}
// Open verifies a sealed value and decodes it into v.
func (s *Signer) Open(sealed string, v any) error {
value, mac, ok := strings.Cut(sealed, ".")
if !ok {
return ErrTampered
}
payload, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return ErrTampered
}
sig, err := base64.RawURLEncoding.DecodeString(mac)
if err != nil {
return ErrTampered
}
if !hmac.Equal(sig, s.macBytes(payload)) {
return ErrTampered
}
if err := json.Unmarshal(payload, v); err != nil {
return ErrTampered
}
return nil
}
func (s *Signer) mac(payload []byte) string {
return base64.RawURLEncoding.EncodeToString(s.macBytes(payload))
}
func (s *Signer) macBytes(payload []byte) []byte {
h := hmac.New(sha256.New, s.key)
h.Write(payload)
return h.Sum(nil)
}
// NewSessionCookie returns the Set-Cookie for a session valid for ttl.
func (s *Signer) NewSessionCookie(sess Session, ttl time.Duration, secure bool) (*http.Cookie, error) {
sess.ExpiresAt = time.Now().Add(ttl)
value, err := s.Seal(sess)
if err != nil {
return nil, err
}
return &http.Cookie{
Name: CookieSession,
Value: value,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}, nil
}
// NewLoginTxCookie returns the short-lived Set-Cookie carrying tx between
// the login redirect and the callback.
func (s *Signer) NewLoginTxCookie(tx LoginTx, secure bool) (*http.Cookie, error) {
value, err := s.Seal(tx)
if err != nil {
return nil, err
}
return &http.Cookie{
Name: CookieLoginTx,
Value: value,
Path: "/api/auth",
MaxAge: int(txTTL.Seconds()),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}, nil
}
// Expired returns a Set-Cookie that deletes the named cookie.
func Expired(name string, secure bool) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}
}
+140
View File
@@ -0,0 +1,140 @@
package auth
import (
"net/http"
"strings"
"testing"
"time"
)
func TestSignerSealOpen(t *testing.T) {
s := NewSigner("test-secret")
tx, err := NewLoginTx("/some/path")
if err != nil {
t.Fatalf("NewLoginTx: %v", err)
}
sealed, err := s.Seal(tx)
if err != nil {
t.Fatalf("Seal: %v", err)
}
var opened LoginTx
if err := s.Open(sealed, &opened); err != nil {
t.Fatalf("Open: %v", err)
}
// Compare fields: time.Time equality also compares the monotonic clock,
// which the JSON roundtrip drops.
if opened.State != tx.State || opened.Nonce != tx.Nonce ||
opened.Verifier != tx.Verifier || opened.ReturnTo != tx.ReturnTo ||
!opened.ExpiresAt.Equal(tx.ExpiresAt) {
t.Errorf("opened = %+v, want %+v", opened, tx)
}
}
func TestSignerRejectsTampering(t *testing.T) {
s := NewSigner("test-secret")
sess := Session{Sub: "user-1", ExpiresAt: time.Now().Add(time.Hour)}
sealed, err := s.Seal(sess)
if err != nil {
t.Fatalf("Seal: %v", err)
}
tests := []struct {
name string
value func(string) string
reason string
}{
{"flipped payload", func(v string) string {
return flipLastChar(strings.Split(v, ".")[0]) + "." + strings.Split(v, ".")[1]
}, "forged payload"},
{"flipped mac", func(v string) string {
return strings.Split(v, ".")[0] + "." + flipLastChar(strings.Split(v, ".")[1])
}, "forged mac"},
{"wrong key", func(v string) string {
other, _ := NewSigner("other-secret").Seal(sess)
return other
}, "signed with a different key"},
{"garbage", func(string) string { return "not-a-sealed-value" }, "garbage"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out Session
if err := s.Open(tt.value(sealed), &out); err == nil {
t.Errorf("Open accepted %s", tt.reason)
}
})
}
}
func TestSessionAndTxCookies(t *testing.T) {
s := NewSigner("test-secret")
sessCookie, err := s.NewSessionCookie(Session{Sub: "u1"}, 2*time.Hour, true)
if err != nil {
t.Fatalf("NewSessionCookie: %v", err)
}
if sessCookie.Name != CookieSession || sessCookie.Path != "/" {
t.Errorf("session cookie = %+v", sessCookie)
}
if !sessCookie.HttpOnly || !sessCookie.Secure || sessCookie.SameSite != http.SameSiteLaxMode {
t.Errorf("session cookie attrs = %+v", sessCookie)
}
var sess Session
if err := s.Open(sessCookie.Value, &sess); err != nil {
t.Fatalf("Open session: %v", err)
}
if !sess.Valid() {
t.Error("session with future ExpiresAt should be valid")
}
sess.ExpiresAt = time.Now().Add(-time.Minute)
if sess.Valid() {
t.Error("expired session should be invalid")
}
tx, err := NewLoginTx("/")
if err != nil {
t.Fatalf("NewLoginTx: %v", err)
}
if !tx.Valid() {
t.Error("fresh login tx should be valid")
}
tx.ExpiresAt = time.Now().Add(-time.Second)
if tx.Valid() {
t.Error("expired login tx should be invalid")
}
tx, _ = NewLoginTx("/")
txCookie, err := s.NewLoginTxCookie(tx, false)
if err != nil {
t.Fatalf("NewLoginTxCookie: %v", err)
}
if txCookie.Name != CookieLoginTx || txCookie.Path != "/api/auth" {
t.Errorf("tx cookie = %+v", txCookie)
}
if !txCookie.HttpOnly || txCookie.Secure {
t.Errorf("tx cookie attrs = %+v", txCookie)
}
expired := Expired(CookieSession, true)
if expired.Name != CookieSession || expired.MaxAge != -1 {
t.Errorf("Expired cookie = %+v", expired)
}
}
func TestNewLoginTxUsesDistinctSecrets(t *testing.T) {
a, _ := NewLoginTx("/")
b, _ := NewLoginTx("/")
if a.State == b.State || a.Nonce == b.Nonce || a.Verifier == b.Verifier {
t.Error("state/nonce/verifier must be fresh per login")
}
}
// flipLastChar swaps the last character of a base64url value so any
// signature or payload check must fail.
func flipLastChar(v string) string {
last := "A"
if strings.HasSuffix(v, last) {
last = "B"
}
return v[:len(v)-1] + last
}
+157
View File
@@ -0,0 +1,157 @@
// Package auth implements the browser login flow against an OpenID Connect
// issuer: authorization code + PKCE (S256), discovery-driven endpoints, and
// RS256 ID token verification via JWKS. The signed-in identity is kept in an
// HMAC-protected session cookie (cookie.go) — tokens never reach the browser.
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// txTTL bounds the login transaction: the gap between redirecting to the
// issuer and the callback that completes it.
const txTTL = 10 * time.Minute
// Config identifies this app to the issuer. Scopes defaults to
// ["openid","profile","email"] when empty.
type Config struct {
Issuer string
ClientID string
ClientSecret string
Scopes []string
}
// Client is the configured OIDC relying party. Discovery happens once in
// New; the client is immutable and safe for concurrent use.
type Client struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
}
// New discovers the issuer's endpoints and returns a ready client.
func New(ctx context.Context, cfg Config) (*Client, error) {
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("discover issuer %s: %w", cfg.Issuer, err)
}
if len(cfg.Scopes) == 0 {
cfg.Scopes = []string{"openid", "profile", "email"}
}
return &Client{
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: cfg.Scopes,
},
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
}, nil
}
// User is the verified identity established by a completed login.
type User struct {
Sub string
Name string
Email string
Roles []string
}
// LoginTx is the per-login state kept in a short-lived cookie between the
// redirect to the issuer and the callback: state and nonce guard against
// CSRF and replay, the verifier carries PKCE, ReturnTo is where the browser
// lands afterwards.
type LoginTx struct {
State string
Nonce string
Verifier string
ReturnTo string
ExpiresAt time.Time
}
// NewLoginTx creates a login transaction with fresh state, nonce, and PKCE
// verifier for the given post-login path.
func NewLoginTx(returnTo string) (LoginTx, error) {
state, err := randomToken(16)
if err != nil {
return LoginTx{}, err
}
nonce, err := randomToken(16)
if err != nil {
return LoginTx{}, err
}
verifier, err := randomToken(48)
if err != nil {
return LoginTx{}, err
}
return LoginTx{
State: state,
Nonce: nonce,
Verifier: verifier,
ReturnTo: returnTo,
ExpiresAt: time.Now().Add(txTTL),
}, nil
}
// Valid reports whether the transaction is still within its TTL.
func (tx LoginTx) Valid() bool { return time.Now().Before(tx.ExpiresAt) }
// AuthCodeURL builds the issuer's authorization URL for tx — PKCE challenge
// and nonce included. redirectURI is the callback URL on the origin the
// browser came from (it must be registered with the issuer).
func (c *Client) AuthCodeURL(tx LoginTx, redirectURI string) string {
return c.oauth.AuthCodeURL(tx.State,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
oauth2.SetAuthURLParam("nonce", tx.Nonce),
oauth2.S256ChallengeOption(tx.Verifier),
)
}
// CompleteCode exchanges the authorization code (PKCE verifier from tx) and
// verifies the returned ID token — signature via JWKS, plus iss/aud/exp and
// the nonce — returning the signed-in user.
func (c *Client) CompleteCode(ctx context.Context, code, redirectURI string, tx LoginTx) (User, error) {
tok, err := c.oauth.Exchange(ctx, code,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
oauth2.VerifierOption(tx.Verifier),
)
if err != nil {
return User{}, fmt.Errorf("exchange authorization code: %w", err)
}
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return User{}, errors.New("token response carries no id_token")
}
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
return User{}, fmt.Errorf("verify id_token: %w", err)
}
if idToken.Nonce != tx.Nonce {
return User{}, errors.New("id_token nonce does not match the login transaction")
}
var claims struct {
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
if err := idToken.Claims(&claims); err != nil {
return User{}, fmt.Errorf("decode id_token claims: %w", err)
}
return User{Sub: idToken.Subject, Name: claims.Name, Email: claims.Email, Roles: claims.Roles}, nil
}
// randomToken returns a URL-safe random string with n bytes of entropy.
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("read randomness: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+155
View File
@@ -0,0 +1,155 @@
// Package config loads runtime configuration from the environment.
//
// Every setting has a safe local default — the database is a local SQLite
// file, so the server runs with `make migrate-up && make run` and no .env
// file, no containers. Point DATABASE_URL at Postgres for the real thing.
// This is the only package that reads os.Getenv — add new settings here
// and to .env.example, never at the point of use.
package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"time"
)
// Config is the validated runtime configuration.
type Config struct {
// Addr is the HTTP listen address.
Addr string
// DatabaseURL selects the database: "sqlite:FILE" for a local file
// (dev default, pure-Go driver) or a "postgres://…" DSN.
DatabaseURL string
// AllowedOrigins lists CORS origins permitted to call the API from a
// browser (the Vite dev server on :5173 by default). "*" allows all.
AllowedOrigins []string
// LogLevel and LogFormat configure the slog handler.
LogLevel slog.Level
LogFormat string // "json" or "text"
// DebugQueries enables Bun query logging (bundebug).
DebugQueries bool
// OIDCIssuer is the OpenID Connect issuer; endpoints are discovered,
// never hardcoded. Auth is enabled when OIDCClientID is non-empty.
OIDCIssuer string
// OIDCClientID and OIDCClientSecret identify this app as a confidential
// client of the issuer. An empty OIDCClientID disables auth entirely:
// /api/auth/* answers 404 and /api/v1 stays open (standalone mode).
OIDCClientID string
OIDCClientSecret string
// AuthSessionSecret signs session cookies. When unset, a random
// ephemeral secret is generated (AuthSessionSecretEphemeral = true) —
// every restart then invalidates all sessions.
AuthSessionSecret string
AuthSessionSecretEphemeral bool
// AuthSessionTTL is how long a session cookie stays valid.
AuthSessionTTL time.Duration
}
// Load reads the environment and returns a validated Config.
func Load() (Config, error) {
cfg := Config{
Addr: env("API_ADDR", ":8080"),
DatabaseURL: env("DATABASE_URL", "sqlite:dev.db"),
AllowedOrigins: splitCSV(env("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")),
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
DebugQueries: env("DB_DEBUG", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", "https://users.k6n.net"), "/"),
OIDCClientID: env("OIDC_CLIENT_ID", ""),
OIDCClientSecret: env("OIDC_CLIENT_SECRET", ""),
AuthSessionSecret: env("AUTH_SESSION_SECRET", ""),
}
sessionTTL, ttlErr := time.ParseDuration(env("AUTH_SESSION_TTL", "168h"))
if ttlErr == nil {
cfg.AuthSessionTTL = sessionTTL
}
var errs []error
level := strings.ToLower(env("LOG_LEVEL", "info"))
switch level {
case "debug":
cfg.LogLevel = slog.LevelDebug
case "info":
cfg.LogLevel = slog.LevelInfo
case "warn":
cfg.LogLevel = slog.LevelWarn
case "error":
cfg.LogLevel = slog.LevelError
default:
errs = append(errs, fmt.Errorf("invalid LOG_LEVEL %q (want debug, info, warn, or error)", level))
}
if cfg.Addr == "" {
errs = append(errs, errors.New("API_ADDR must not be empty"))
}
if cfg.DatabaseURL == "" {
errs = append(errs, errors.New("DATABASE_URL must not be empty"))
}
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
errs = append(errs, fmt.Errorf("invalid LOG_FORMAT %q (want json or text)", cfg.LogFormat))
}
if ttlErr != nil {
errs = append(errs, fmt.Errorf("invalid AUTH_SESSION_TTL: %v", ttlErr))
} else if cfg.AuthSessionTTL <= 0 {
errs = append(errs, errors.New("AUTH_SESSION_TTL must be positive"))
}
if _, err := url.Parse(cfg.OIDCIssuer); err != nil || !strings.HasPrefix(cfg.OIDCIssuer, "https://") && !strings.HasPrefix(cfg.OIDCIssuer, "http://") {
errs = append(errs, fmt.Errorf("invalid OIDC_ISSUER %q (want an http(s) URL)", cfg.OIDCIssuer))
}
if cfg.OIDCClientID != "" && cfg.OIDCClientSecret == "" {
errs = append(errs, errors.New("OIDC_CLIENT_SECRET is required when OIDC_CLIENT_ID is set"))
}
if cfg.AuthSessionSecret == "" {
secret, err := randomSecret()
if err != nil {
return Config{}, fmt.Errorf("generate session secret: %w", err)
}
cfg.AuthSessionSecret = secret
cfg.AuthSessionSecretEphemeral = true
}
if len(errs) > 0 {
return Config{}, fmt.Errorf("invalid config: %w", errors.Join(errs...))
}
return cfg, nil
}
// randomSecret returns a URL-safe random string for signing session
// cookies when AUTH_SESSION_SECRET is not provided.
func randomSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// env returns the environment variable or fallback when unset/blank.
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return fallback
}
// splitCSV splits a comma-separated env value, dropping blank entries.
func splitCSV(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+68
View File
@@ -0,0 +1,68 @@
// Package database owns the Bun connection and the migration registry.
package database
import (
"database/sql"
"fmt"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/driver/pgdriver"
"github.com/uptrace/bun/driver/sqliteshim"
"github.com/uptrace/bun/extra/bundebug"
"github.com/uptrace/bun/migrate"
"github.com/uptrace/bun/schema"
"git.k6n.net/mats/fullstack-template/internal/config"
migrations "git.k6n.net/mats/fullstack-template/internal/database/migrations"
)
// Open connects to the database named by cfg.DatabaseURL:
//
// sqlite:FILE?params Bun + modernc sqlite (pure Go, no CGO) — dev default
// postgres://… Bun + pgdriver against Postgres
//
// It does not ping — call PingContext during startup to fail fast.
func Open(cfg config.Config) (*bun.DB, error) {
var sqldb *sql.DB
var dialect schema.Dialect
switch {
case strings.HasPrefix(cfg.DatabaseURL, "sqlite:"):
dsn := strings.TrimPrefix(cfg.DatabaseURL, "sqlite:")
// Sensible dev defaults when the DSN brings no pragmas of its own:
// WAL keeps readers unblocked during writes, busy_timeout waits out
// the remaining write locks instead of erroring, foreign_keys is
// what everyone expects to be on.
if !strings.Contains(dsn, "_pragma") {
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
}
var err error
sqldb, err = sql.Open(sqliteshim.ShimName, dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", dsn, err)
}
dialect = sqlitedialect.New()
default:
sqldb = sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(cfg.DatabaseURL)))
dialect = pgdialect.New()
}
db := bun.NewDB(sqldb, dialect)
if cfg.DebugQueries {
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
}
return db, nil
}
// NewMigrator returns a bun migrator over the migrations registered in
// internal/database/migrations. See cmd/migrate for the CLI around it.
func NewMigrator(db *bun.DB) *migrate.Migrator {
return migrate.NewMigrator(db, migrations.Migrations)
}
@@ -0,0 +1,84 @@
package database_test
import (
"context"
"errors"
"testing"
"git.k6n.net/mats/fullstack-template/internal/config"
"git.k6n.net/mats/fullstack-template/internal/database"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// The whole persistence stack against an in-memory SQLite database:
// migrations produce a working schema and the real BunRepository
// round-trips items. This is also the cheapest way to catch dialect drift
// between the model and the migrations — no Postgres container required.
func TestSQLiteSchemaRoundTrip(t *testing.T) {
ctx := context.Background()
db, err := database.Open(config.Config{DatabaseURL: "sqlite:file:mem?mode=memory&cache=shared"})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// One connection keeps the shared in-memory database stable across
// the pool (and mirrors how single-writer sqlite behaves anyway).
db.SetMaxOpenConns(1)
migrator := database.NewMigrator(db)
if err := migrator.Init(ctx); err != nil {
t.Fatalf("migrator init: %v", err)
}
if _, err := migrator.Migrate(ctx); err != nil {
t.Fatalf("migrate up: %v", err)
}
svc := items.NewService(items.NewBunRepository(db))
created, err := svc.Create(ctx, items.CreateInput{Title: "first", Description: "from sqlite"})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == 0 || created.Status != items.StatusActive {
t.Fatalf("create returned %+v", created)
}
got, err := svc.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Title != "first" || got.CreatedAt.IsZero() {
t.Fatalf("get returned %+v", got)
}
archived, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: ptr(items.StatusArchived)})
if err != nil {
t.Fatalf("update: %v", err)
}
if archived.Status != items.StatusArchived {
t.Fatalf("update returned %+v", archived)
}
listed, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 {
t.Fatalf("list archived len = %d, want 1", len(listed))
}
if err := svc.Delete(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := svc.Get(ctx, created.ID); !errors.Is(err, items.ErrNotFound) {
t.Fatalf("get after delete: got %v, want ErrNotFound", err)
}
// The down migration must undo the up migration cleanly.
if _, err := migrator.Rollback(ctx); err != nil {
t.Fatalf("rollback: %v", err)
}
}
func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,31 @@
package migrations
import (
"context"
"github.com/uptrace/bun"
"git.k6n.net/mats/fullstack-template/internal/items"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.NewCreateTable().
Model((*items.Item)(nil)).
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
_, err = db.NewCreateIndex().
Model((*items.Item)(nil)).
IfNotExists().
Index("items_status_idx").
Column("status").
Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropTable().Model((*items.Item)(nil)).IfExists().Exec(ctx)
return err
})
}
@@ -0,0 +1,18 @@
// Package migrations holds the schema migrations: one Go file per
// migration, named <YYYYMMDDHHMMSS>_<snake_name>.go, each registering
// itself into Migrations via init(). `make migrate-new` scaffolds the next
// one with a fresh timestamp.
//
// Prefer model-driven DDL (db.NewCreateTable().Model((*pkg.Thing)(nil))) —
// bun emits dialect-correct SQL for both sqlite and postgres, so migrations
// stay portable. Reach for db.Exec with dialect-specific SQL only when a
// model can't express it.
package migrations
import "github.com/uptrace/bun/migrate"
// Migrations is the registry consumed by internal/database.NewMigrator.
// The directory option tells `migrate new` where to write (from backend/).
var Migrations = migrate.NewMigrations(
migrate.WithMigrationsDirectory("internal/database/migrations"),
)
+89
View File
@@ -0,0 +1,89 @@
// Package items is the reference vertical slice: model, repository
// interface, Bun implementation, and service. To add a new resource, copy
// this package's shape and register its routes in internal/api.
package items
import (
"context"
"errors"
"time"
"github.com/uptrace/bun"
)
// Status is the lifecycle state of an item, stored as text.
type Status string
const (
StatusActive Status = "active"
StatusArchived Status = "archived"
)
// Valid reports whether s is a known status value.
func (s Status) Valid() bool {
return s == StatusActive || s == StatusArchived
}
// Item is the persisted model. Struct tags are Bun's mapping; json tags
// define the wire format (camelCase, matching the frontend).
type Item struct {
bun.BaseModel `bun:"table:items,alias:i"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Title string `bun:"title,notnull" json:"title"`
Description string `bun:"description,nullzero" json:"description"`
Status Status `bun:"status,notnull,default:'active'" json:"status"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updatedAt"`
}
// BeforeAppendModel stamps timestamps on INSERT and UPDATE queries.
// Postgres stores microsecond UTC; truncate + UTC so values set in Go
// (PATCH responses) match what round-trips through the database.
func (i *Item) BeforeAppendModel(_ context.Context, query bun.Query) error {
now := time.Now().UTC().Truncate(time.Microsecond)
switch query.(type) {
case *bun.InsertQuery:
i.CreatedAt, i.UpdatedAt = now, now
case *bun.UpdateQuery:
i.UpdatedAt = now
}
return nil
}
// Filter narrows and pages List queries. Apply normalizeFilter (via the
// service) before handing one to a repository.
type Filter struct {
Status Status
Limit int
Offset int
}
// List result paging defaults, applied by normalizeFilter.
const (
DefaultLimit = 50
MaxLimit = 100
)
// Repository is the persistence contract for items. The service depends on
// this interface — not on Bun — so tests use in-memory fakes and other
// backends remain possible.
type Repository interface {
List(ctx context.Context, f Filter) ([]Item, error)
Get(ctx context.Context, id int64) (Item, error)
Create(ctx context.Context, item *Item) error
Update(ctx context.Context, item *Item) error
Delete(ctx context.Context, id int64) error
}
// ErrNotFound is returned by repositories and services when an item does
// not exist. Handlers map it to 404.
var ErrNotFound = errors.New("item not found")
// ValidationError reports an invalid field value. Handlers map it to 400.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }
+81
View File
@@ -0,0 +1,81 @@
package items
import (
"context"
"database/sql"
"errors"
"github.com/uptrace/bun"
)
// BunRepository is the Postgres implementation of Repository. It is the
// only file in the package that speaks Bun/SQL.
type BunRepository struct {
db *bun.DB
}
// NewBunRepository wraps a *bun.DB opened by internal/database.
func NewBunRepository(db *bun.DB) *BunRepository {
return &BunRepository{db: db}
}
// List returns items newest-first, honoring the (already normalized) filter.
// An empty result set encodes as [] on the wire, not null.
func (r *BunRepository) List(ctx context.Context, f Filter) ([]Item, error) {
var out []Item
q := r.db.NewSelect().Model(&out)
if f.Status != "" {
q = q.Where("i.status = ?", f.Status)
}
if err := q.
Order("i.created_at DESC", "i.id DESC").
Limit(f.Limit).
Offset(f.Offset).
Scan(ctx); err != nil {
return nil, err
}
if out == nil {
out = []Item{}
}
return out, nil
}
// Get fetches a single item by id, mapping missing rows to ErrNotFound.
func (r *BunRepository) Get(ctx context.Context, id int64) (Item, error) {
var item Item
err := r.db.NewSelect().Model(&item).Where("i.id = ?", id).Scan(ctx)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound
}
return item, err
}
// Create inserts item and reads back the generated columns.
func (r *BunRepository) Create(ctx context.Context, item *Item) error {
return r.db.NewInsert().Model(item).Returning("*").Scan(ctx, item)
}
// Update persists item (matched by primary key); ErrNotFound when the row
// is gone.
func (r *BunRepository) Update(ctx context.Context, item *Item) error {
res, err := r.db.NewUpdate().Model(item).WherePK().Exec(ctx)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// Delete removes an item by id; ErrNotFound when the row is gone.
func (r *BunRepository) Delete(ctx context.Context, id int64) error {
res, err := r.db.NewDelete().Model((*Item)(nil)).Where("i.id = ?", id).Exec(ctx)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
package items
import (
"context"
"strings"
)
// Field length limits enforced on input.
const (
maxTitleLen = 200
maxDescriptionLen = 2000
)
// Service holds the business rules for items. It depends only on the
// Repository interface; main wires the Bun implementation in.
type Service struct {
repo Repository
}
// NewService returns a Service over the given repository.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// CreateInput is the request body for creating an item.
type CreateInput struct {
Title string `json:"title"`
Description string `json:"description"`
Status Status `json:"status"`
}
// UpdateInput patches only provided fields (nil = leave untouched), which
// is why every field is a pointer.
type UpdateInput struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *Status `json:"status"`
}
// List validates the filter and delegates to the repository.
func (s *Service) List(ctx context.Context, f Filter) ([]Item, error) {
f, err := normalizeFilter(f)
if err != nil {
return nil, err
}
return s.repo.List(ctx, f)
}
// Get returns one item.
func (s *Service) Get(ctx context.Context, id int64) (Item, error) {
return s.repo.Get(ctx, id)
}
// Create validates input and stores a new item.
func (s *Service) Create(ctx context.Context, in CreateInput) (Item, error) {
item := Item{
Title: strings.TrimSpace(in.Title),
Description: strings.TrimSpace(in.Description),
}
if item.Title == "" {
return Item{}, &ValidationError{Field: "title", Reason: "required"}
}
if len(item.Title) > maxTitleLen {
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
}
if len(item.Description) > maxDescriptionLen {
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
}
switch in.Status {
case "":
item.Status = StatusActive
case StatusActive, StatusArchived:
item.Status = in.Status
default:
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
}
if err := s.repo.Create(ctx, &item); err != nil {
return Item{}, err
}
return item, nil
}
// Update applies a partial patch to an existing item.
func (s *Service) Update(ctx context.Context, id int64, in UpdateInput) (Item, error) {
if in.Title == nil && in.Description == nil && in.Status == nil {
return Item{}, &ValidationError{Field: "body", Reason: "at least one field must be set"}
}
item, err := s.repo.Get(ctx, id)
if err != nil {
return Item{}, err
}
if in.Title != nil {
item.Title = strings.TrimSpace(*in.Title)
if item.Title == "" {
return Item{}, &ValidationError{Field: "title", Reason: "required"}
}
if len(item.Title) > maxTitleLen {
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
}
}
if in.Description != nil {
item.Description = strings.TrimSpace(*in.Description)
if len(item.Description) > maxDescriptionLen {
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
}
}
if in.Status != nil {
if !in.Status.Valid() {
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
}
item.Status = *in.Status
}
if err := s.repo.Update(ctx, &item); err != nil {
return Item{}, err
}
return item, nil
}
// Delete removes an item.
func (s *Service) Delete(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}
// normalizeFilter applies paging defaults and validates the status filter.
func normalizeFilter(f Filter) (Filter, error) {
switch f.Status {
case "":
case StatusActive, StatusArchived:
default:
return Filter{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
}
if f.Limit <= 0 {
f.Limit = DefaultLimit
}
if f.Limit > MaxLimit {
f.Limit = MaxLimit
}
if f.Offset < 0 {
f.Offset = 0
}
return f, nil
}
+231
View File
@@ -0,0 +1,231 @@
package items_test
import (
"context"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// fakeRepo is an in-memory items.Repository — the point of the Repository
// interface is that these tests never touch a database.
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
}
func newService() (*items.Service, *fakeRepo) {
repo := newFakeRepo()
return items.NewService(repo), repo
}
func TestCreateValidation(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
tests := []struct {
name string
in items.CreateInput
want string // expected ValidationError.Field
}{
{"empty title", items.CreateInput{Title: " "}, "title"},
{"long title", items.CreateInput{Title: strings.Repeat("x", 201)}, "title"},
{"bad status", items.CreateInput{Title: "ok", Status: "nope"}, "status"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := svc.Create(ctx, tt.in)
var vErr *items.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("got error %v, want *items.ValidationError", err)
}
if vErr.Field != tt.want {
t.Errorf("field = %q, want %q", vErr.Field, tt.want)
}
})
}
}
func TestCreateDefaults(t *testing.T) {
svc, _ := newService()
item, err := svc.Create(context.Background(), items.CreateInput{Title: " hello "})
if err != nil {
t.Fatalf("Create: %v", err)
}
if item.Title != "hello" {
t.Errorf("title = %q, want trimmed %q", item.Title, "hello")
}
if item.Status != items.StatusActive {
t.Errorf("status = %q, want default %q", item.Status, items.StatusActive)
}
}
func TestUpdatePatchesOnlyProvidedFields(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
created, err := svc.Create(ctx, items.CreateInput{Title: "original", Description: "keep me"})
if err != nil {
t.Fatalf("Create: %v", err)
}
archived := items.StatusArchived
updated, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: &archived})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Status != items.StatusArchived {
t.Errorf("status = %q, want %q", updated.Status, items.StatusArchived)
}
if updated.Title != "original" || updated.Description != "keep me" {
t.Errorf("untouched fields changed: %+v", updated)
}
}
func TestUpdateRequiresField(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
created, _ := svc.Create(ctx, items.CreateInput{Title: "x"})
_, err := svc.Update(ctx, created.ID, items.UpdateInput{})
var vErr *items.ValidationError
if !errors.As(err, &vErr) || vErr.Field != "body" {
t.Fatalf("got %v, want validation error on body", err)
}
}
func TestUpdateNotFound(t *testing.T) {
svc, _ := newService()
title := "x"
_, err := svc.Update(context.Background(), 999, items.UpdateInput{Title: &title})
if !errors.Is(err, items.ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
func TestListFilterAndPaging(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
svc.Create(ctx, items.CreateInput{Title: "one"})
svc.Create(ctx, items.CreateInput{Title: "two", Status: items.StatusArchived})
svc.Create(ctx, items.CreateInput{Title: "three", Status: items.StatusArchived})
got, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if _, err := svc.List(ctx, items.Filter{Status: "bogus"}); err == nil {
t.Error("List with bogus status: want validation error")
}
got, err = svc.List(ctx, items.Filter{Limit: 1})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != items.DefaultLimit && len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
}
func TestDeleteFlow(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
item, _ := svc.Create(ctx, items.CreateInput{Title: "gone"})
if err := svc.Delete(ctx, item.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := svc.Get(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
t.Fatalf("Get after delete: got %v, want ErrNotFound", err)
}
if err := svc.Delete(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
t.Fatalf("second delete: got %v, want ErrNotFound", err)
}
}