Result box: color-code by status + friendly error messages from error codes (#105)
CI / test (push) Successful in 23s
CI / docker (push) Skipped

- Backend create/can validation paths emit machine-readable error codes
  (slug_taken, slug_invalid, content_empty, content_too_large,
  expiry_invalid, rate_limited, ...) alongside the human message
- new.html JS maps codes to plain-language guidance with generic fallback
- Result card colored via --ok/--err/--warn left border (result-ok/err/warn)
- docs/API.md error section documents the code field
- Tests assert the code on every validation path
This commit is contained in:
2026-09-09 17:28:25 -05:00
parent c35531e03e
commit 9df6224a27
7 changed files with 216 additions and 18 deletions
+32 -3
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
@@ -51,6 +52,30 @@ func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writeErrCode emits a JSON error with a machine-readable code (#105): the
// new-page JS maps known codes to plain-language messages, API consumers get
// the stable `code` field alongside the human `error` text.
func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": msg, "code": code})
}
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
// code (#105).
func createErrCode(err error) string {
switch {
case errors.Is(err, store.ErrSlugTaken):
return "slug_taken"
case errors.Is(err, store.ErrReservedSlug):
return "slug_reserved"
case errors.Is(err, store.ErrInvalidSlug):
return "slug_invalid"
case strings.Contains(err.Error(), "expires_in"):
return "expiry_invalid"
default:
return "invalid_request"
}
}
// Routes returns the HTTP handler for the server.
func (a *apiServer) Routes() http.Handler {
return a.routes()
@@ -158,14 +183,18 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
var p store.Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
writeBodyTooLarge(w)
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
return
}
writeErr(w, 400, "invalid json body")
return
}
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
writeErr(w, status, msg)
if status == http.StatusRequestEntityTooLarge {
writeErrCode(w, status, "content_too_large", msg)
} else {
writeErrCode(w, status, "content_empty", msg)
}
return
}
// #86: bound free-form metadata at create time
@@ -203,7 +232,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
p.ViewerID = currentViewerID(r)
created, err := a.store.CreatePaste(&p)
if err != nil {
writeErr(w, 400, err.Error())
writeErrCode(w, 400, createErrCode(err), err.Error())
return
}
writeJSON(w, 201, map[string]any{