Day 55 — validation & API errors
Day 55 — validation & API errors
Stage VI · ~3h
Goal: Centralize input validation and emit consistent problem/error JSON—field-level details, stable error codes, and clean mapping from domain errors to HTTP.
Why this day exists
APIs rot when every handler invents its own error shape:
{"err":"bad"}
{"message":"Name missing"}
{"error":"not found"}Clients need predictability. Operators need codes. Security needs to avoid leaking internals. Today turns Day 53/54 handlers into a single error language.
Theory 1 — Error envelope
Adopt one envelope for the service:
{
"error": {
"code": "validation_failed",
"message": "request validation failed",
"details": [
{"field": "name", "issue": "required"},
{"field": "email", "issue": "invalid_format"}
]
}
}| Field | Purpose |
|---|---|
code |
Stable machine token (snake_case) |
message |
Human summary |
details |
Optional field issues |
Optional RFC 9457 Problem Details (application/problem+json)—same idea, standard fields (type, title, status, detail, instance). Lab may use either; document choice.
{
"type": "about:blank",
"title": "validation_failed",
"status": 400,
"detail": "request validation failed",
"errors": [{"field":"name","issue":"required"}]
}Theory 2 — Validation layers
- Transport: JSON syntax, Content-Type, body size
- Schema: required fields, types, ranges, formats
- Domain: unique email, inventory rules, authz
Do not conflate layers—malformed JSON is 400; “item not found” is 404; “not owner” is 403.
Small validator without frameworks
type FieldError struct {
Field string `json:"field"`
Issue string `json:"issue"`
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string { return "validation failed" }
func (e *ValidationError) Add(field, issue string) {
e.Fields = append(e.Fields, FieldError{Field: field, Issue: issue})
}
func (e *ValidationError) Err() error {
if len(e.Fields) == 0 {
return nil
}
return e
}func validateCreateItem(name string) error {
var v ValidationError
name = strings.TrimSpace(name)
if name == "" {
v.Add("name", "required")
} else if len(name) > 100 {
v.Add("name", "too_long")
}
return v.Err()
}Accumulate all field errors when cheap—clients fix multiple issues in one round-trip.
Theory 3 — Mapping domain → HTTP
func writeDomainErr(w http.ResponseWriter, log *slog.Logger, err error) {
var ve *ValidationError
switch {
case errors.As(err, &ve):
writeError(w, http.StatusBadRequest, "validation_failed", "request validation failed", ve.Fields)
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "resource not found", nil)
case errors.Is(err, store.ErrConflict):
writeError(w, http.StatusConflict, "conflict", "resource already exists", nil)
case errors.Is(err, ErrUnauthorized):
writeError(w, http.StatusUnauthorized, "unauthorized", "login required", nil)
case errors.Is(err, ErrForbidden):
writeError(w, http.StatusForbidden, "forbidden", "forbidden", nil)
default:
log.Error("handler", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "internal error", nil)
}
}Never return raw err.Error() for unknown 500s to clients. Log the real error server-side with request id.
Theory 4 — Content negotiation lite
- Require
Content-Type: application/jsonon bodies that need JSON
- Respond
415if wrong type
- Always set response
Content-Typeto your error/success type
- Limit body size before decode
func readJSON(r *http.Request, dst any) error {
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
return errUnsupportedMedia
}
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("%w: %v", errBadJSON, err)
}
return nil
}Theory 5 — Idempotent validation messages
Stable issue tokens (required, too_long, invalid_format) beat free-form sentences for i18n and client logic. Message field can be English for humans.
Regexp from Day 48 for email/slug validation—compile once at package init:
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
func validateEmail(s string) error {
var v ValidationError
s = strings.TrimSpace(s)
if s == "" {
v.Add("email", "required")
} else if !emailRe.MatchString(s) {
v.Add("email", "invalid_format")
}
return v.Err()
}Theory 6 — Sentinel errors and wrapping
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
// store package
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")Handlers and services return wrapped sentinels (fmt.Errorf("get item: %w", store.ErrNotFound)). Mapping uses errors.Is / errors.As only—string matching error text is fragile.
Worked example — writeError helper
type errorBody struct {
Error errorPayload `json:"error"`
}
type errorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
Details []FieldError `json:"details,omitempty"`
}
func writeError(w http.ResponseWriter, status int, code, msg string, details []FieldError) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{
Code: code, Message: msg, Details: details,
}})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}Lab 1 — Integrate into service
mkdir -p ~/lab/90daysofx/01-go/day55
cd ~/lab/90daysofx/01-go/day55
go mod init example.com/day55Upgrade Day 53/54 handlers to use central writeDomainErr. Delete ad-hoc http.Error and one-off JSON maps for failures.
Lab 2 — Validation matrix tests
| Case | Expect |
|---|---|
| Missing name | 400 validation_failed + field detail |
| Name too long | 400 |
| Bad email on register | 400 |
| Unknown JSON field | 400 (if DisallowUnknownFields) |
| Not found | 404 not_found no details |
| No auth | 401 |
| Duplicate email | 409 |
Assert JSON paths with encoding/json into structs—not only substrings.
var body struct {
Error struct {
Code string `json:"code"`
Details []struct {
Field string `json:"field"`
Issue string `json:"issue"`
} `json:"details"`
} `json:"error"`
}Lab 3 — Internal error hygiene
Force a repo error (inject fake repo in test) → client body must not contain SQL text; server logs must contain the real error (slog with "err" key).
Lab 4 — Optional problem+json
Implement writeProblem with Content-Type: application/problem+json for one endpoint; compare DX to your envelope. Pick a standard for the rest of Stage VI and document it in README.
Lab 5 — OpenAPI lite note
Write a short markdown table of error codes used by the service—proto-catalog for clients:
| code | HTTP | meaning |
|---|---|---|
| validation_failed | 400 | field details present |
| unauthorized | 401 | missing/invalid credentials |
| forbidden | 403 | authenticated but not allowed |
| not_found | 404 | resource missing |
| conflict | 409 | unique constraint / duplicate |
| internal | 500 | unexpected |
Common gotchas
| Gotcha | Fix |
|---|---|
| Different shapes per handler | One helper |
| Leaking internals on 500 | Generic message + slog |
| Validation only client-side | Server always validates |
| Stringly field paths | Nested field like address.zip |
| Using 500 for bad input | 400/422 |
| Ignoring multiple field errors | Accumulate in ValidationError |
| Matching error strings | errors.Is / As |
| Forgetting body limit | io.LimitReader |
Checkpoint
- Single error envelope everywhere
- Field-level validation details
- Domain mapping switch complete
- Tests cover 400/401/404/409
- 500 does not leak internals
- Error code catalog documented
Commit
git add .
git commit -m "day55: validation and consistent API error envelope"Write three personal gotchas before continuing.
Tomorrow
Day 56 — elective branch A: either a gRPC unary intro or deepen REST (OpenAPI notes, ETags, bulk endpoints)—choose deliberately.