published_at is now the first-publish time (null for drafts, kept across edits
and unpublish/republish), slugs collide to -2/-3 and regenerate on retitle,
every error is {"error"} with 400/401/403/404/405/409 pinned, timestamps are
RFC 3339 UTC. GUIDELINES pins the contract; each backend passes the same 59-check
end-to-end script.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
108 lines
2.8 KiB
Go
108 lines
2.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type app struct {
|
|
db *pgxpool.Pool
|
|
secret []byte
|
|
}
|
|
|
|
func main() {
|
|
port := envOr("PORT", "8080")
|
|
dbURL := mustEnv("DATABASE_URL")
|
|
secret := mustEnv("AUTH_SECRET")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
pool, err := openDB(ctx, dbURL)
|
|
if err != nil {
|
|
log.Fatalf("database: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
a := &app{db: pool, secret: []byte(secret)}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Write([]byte("ok"))
|
|
})
|
|
mux.HandleFunc("POST /auth/register", a.register)
|
|
mux.HandleFunc("POST /auth/login", a.login)
|
|
mux.HandleFunc("GET /posts", a.listPosts)
|
|
mux.HandleFunc("GET /posts/{slug}", a.getPost)
|
|
mux.HandleFunc("POST /posts", a.requireAuth(a.createPost))
|
|
mux.HandleFunc("PUT /posts/{id}", a.requireAuth(a.updatePost))
|
|
mux.HandleFunc("DELETE /posts/{id}", a.requireAuth(a.deletePost))
|
|
|
|
srv := &http.Server{Addr: ":" + port, Handler: jsonErrors(mux), ReadHeaderTimeout: 5 * time.Second}
|
|
log.Printf("listening on :%s", port)
|
|
log.Fatal(srv.ListenAndServe())
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func mustEnv(key string) string {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
log.Fatalf("%s must be set", key)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// jsonErrors swaps net/http's plain-text 404 (no route) and 405 (known path,
|
|
// wrong method) for the API's JSON error body.
|
|
func jsonErrors(mux *http.ServeMux) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, pattern := mux.Handler(r); pattern == "" {
|
|
w = statusAsJSON{w}
|
|
}
|
|
mux.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// statusAsJSON turns the status net/http writes into writeErr's body and
|
|
// drops net/http's own text.
|
|
type statusAsJSON struct{ http.ResponseWriter }
|
|
|
|
func (w statusAsJSON) WriteHeader(code int) {
|
|
writeErr(w.ResponseWriter, code, strings.ToLower(http.StatusText(code)))
|
|
}
|
|
|
|
func (w statusAsJSON) Write(b []byte) (int, error) { return len(b), nil }
|
|
|
|
// internalErr logs the cause and hides it from the client.
|
|
func internalErr(w http.ResponseWriter, err error) {
|
|
log.Printf("internal error: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
|
|
// decode reads a JSON request body (capped at 1 MB) into v.
|
|
func decode(w http.ResponseWriter, r *http.Request, v any) error {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
return json.NewDecoder(r.Body).Decode(v)
|
|
}
|