refactor: extract API handlers into internal packages

Move HTTP handler logic out of main into internal api and server packages,
slimming main to a thin entrypoint. Bundles bug fixes: login nil-panic,
empty-password validation, chirp sort ordering, duplicate-email 409, and
restricting the file server to the assets directory.
This commit is contained in:
Stevan Freeborn
2026-08-15 22:07:00 -05:00
parent aaa913ccab
commit cf79c8a626
12 changed files with 966 additions and 817 deletions
+58
View File
@@ -0,0 +1,58 @@
// Package api provides small HTTP helpers shared by the HTTP handlers.
package api
import (
"encoding/json"
"net/http"
)
// Error is an error that serializes to the shape {"error": message}.
type Error struct {
Err string `json:"error"`
}
// Error returns the error message.
func (e Error) Error() string { return e.Err }
// NewError returns an Error with the given message.
func NewError(msg string) error { return Error{Err: msg} }
// WriteJSON writes a JSON response. A nil payload writes only the status code.
func WriteJSON(w http.ResponseWriter, status int, payload any) {
if payload == nil {
w.WriteHeader(status)
return
}
data, err := json.Marshal(payload)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"Failed to encode JSON response"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}
// WriteError writes an error response with the given status code.
func WriteError(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, Error{Err: msg})
}
// DecodeJSON decodes a JSON request body into a new value of type T.
func DecodeJSON[T any](r *http.Request) (*T, error) {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
var data *T
if err := decoder.Decode(&data); err != nil {
return nil, NewError("Failed to deserialize request")
}
return data, nil
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWriteError(t *testing.T) {
rec := httptest.NewRecorder()
WriteError(rec, http.StatusBadRequest, "nope")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
contentType := rec.Header().Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", contentType)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if body["error"] != "nope" {
t.Fatalf("error message = %q, want %q", body["error"], "nope")
}
}
func TestWriteJSONNilPayload(t *testing.T) {
rec := httptest.NewRecorder()
WriteJSON(rec, http.StatusNoContent, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if rec.Body.Len() != 0 {
t.Fatalf("body = %q, want empty", rec.Body.String())
}
}
func TestDecodeJSON(t *testing.T) {
type userRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"email":"alice@example.com","password":"hunter2"}`))
got, err := DecodeJSON[userRequest](req)
if err != nil {
t.Fatalf("DecodeJSON returned error: %v", err)
}
if got == nil || got.Email != "alice@example.com" || got.Password != "hunter2" {
t.Fatalf("DecodeJSON = %+v, want alice@example.com/hunter2", got)
}
}
func TestDecodeJSONInvalid(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json"))
_, err := DecodeJSON[struct{}](req)
if err == nil {
t.Fatal("DecodeJSON returned nil error for invalid body")
}
if err.Error() != "Failed to deserialize request" {
t.Fatalf("error = %q, want %q", err.Error(), "Failed to deserialize request")
}
}
func TestErrorSerializesToJSON(t *testing.T) {
rec := httptest.NewRecorder()
WriteJSON(rec, http.StatusConflict, Error{Err: "boom"})
if !strings.Contains(rec.Body.String(), `"boom"`) {
t.Fatalf("body = %q, want it to contain the error message", rec.Body.String())
}
}