Day 099 — Request Validation¶
Month 4 · Week 3 · ⬅ Day 098 · Day 100 ➡ · Journal index
🎯 Learning Objective¶
Validate a decoded request body and return all field errors at once with the right status code, separating "I can't parse this" from "this breaks a rule".
📚 Topics¶
- Decode-then-validate; collecting
FieldErrors - 400 vs 422;
net/mail.ParseAddress; trimming before measuring
📖 Reading / Sources¶
📝 Notes¶
- Two failure classes, two codes. A body that won't decode is a
400 Bad Request(syntax). A body that decodes but violates a business rule is422 Unprocessable Entity(semantics). Don't collapse them. → [[http-status-codes]] - Validate after decoding, on the request type — a
Validate()method on the struct, not buried in the handler, so it is unit-testable with no server. - Collect every error, don't bail on the first. Append to a
map[string]stringkeyed by field; one bad round-trip beats N. → [[fail-collect]] - Trim before you measure.
strings.TrimSpace(name) == ""catches the all-whitespace name that a naivelen(name) > 0would wave through. - Don't hand-roll an email regex.
mail.ParseAddressis the stdlib check; it accepts what an SMTP layer would actually accept. - A
map(not[]error) gives a stable JSON shape and one message per field; keep the first message set per field so general rules win. → [[idempotent-errors]]
💻 Code Examples¶
type FieldErrors map[string]string
func (fe FieldErrors) Add(field, msg string) {
if _, exists := fe[field]; !exists { // keep the first error per field
fe[field] = msg
}
}
func (r CreateUserRequest) Validate() FieldErrors {
fe := FieldErrors{}
if strings.TrimSpace(r.Name) == "" {
fe.Add("name", "is required")
}
if _, err := mail.ParseAddress(r.Email); err != nil {
fe.Add("email", "must be a valid email address")
}
if r.Age < 18 {
fe.Add("age", "must be at least 18")
}
return fe // len==0 means valid
}
Full code:
examples/month-04/validation/main.go· Run:go run ./examples/month-04/validation
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
Validate() collecting all field errors |
✅ | exercises/month-04/week-3/validate |
First-error-wins Add semantics |
✅ | exercises/month-04/week-3/validate |
🐛 Mistakes Made¶
- Returned
400for a valid-JSON-but-invalid-age body. Switched to422. - Bailed on the first error; clients had to retry repeatedly. Now I collect all.
❓ Open Questions¶
- When does a reflection/tag-driven validator (
go-playground/validator) earn its keep over hand-writtenValidate()methods? (Answer: many fields / shared rule sets; until then hand-written is clearer and dependency-free.)
🧠 Active Recall (answer without looking)¶
-
Q: Malformed JSON vs a well-formed body that breaks a rule — which status each?
A
Malformed/undecodable JSON → `400 Bad Request`. Decodes fine but violates a business rule → `422 Unprocessable Entity`. -
Q: Why return a
map[string]stringof errors instead of stopping at the first?
A
It reports every problem in one response (better UX, fewer round-trips) and gives a stable, parseable JSON shape keyed by field name.
🪶 Feynman Reflection¶
Parsing answers "is this even JSON I understand?" Validation answers "do the values make sense?" They are different questions with different HTTP codes. I gather all the value complaints into one labelled list so the caller fixes everything in a single edit.
🕳️ Knowledge Gaps¶
- Cross-field rules (e.g.
endDateafterstartDate) and how to surface them.
✅ Summary¶
I can decode strictly, validate on the request type, collect every field error, and answer with 400 vs 422 correctly.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 100: per-request structured logging with
log/slog.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(examples): request validation with collected field errors (day 099)