feat(valid): initial stable release v0.9.0

Struct validation backed by go-playground/validator/v10 with xerrors integration and pluggable i18n message providers.

What's included:
- Validator interface with Struct(v any) error method
- New(...Option) constructor with WithMessageProvider functional option
- MessageProvider interface for i18n; DefaultMessages (EN) and SpanishMessages (ES) built in
- ValidationErrors mapped to xerrors.ErrInvalidInput with field and tag context keys
- InvalidValidationError (non-struct input) mapped to xerrors.ErrInternal
- Full playground.ValidationErrors attached via WithError for callers needing all failures

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-18 21:02:26 +00:00
commit 328b80c060
16 changed files with 955 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
# ADR-002: xerrors Integration for Validation Errors
**Status:** Accepted
**Date:** 2026-03-18
## Context
Validation failures need to be distinguishable from other error categories (not found, internal, already exists) so that HTTP handlers can map them to the correct status codes without inspecting error messages. The `xerrors` module provides a typed error system with stable `Code` values for this purpose.
The `go-playground/validator` library returns two distinct error types for failures:
1. `*playground.InvalidValidationError` — the argument passed to `Struct()` is not a struct (programmer error).
2. `playground.ValidationErrors` — one or more field constraints failed (expected user input error).
These two cases must be mapped to different xerrors codes: the first is an internal bug, the second is a bad-input condition.
## Decision
`Struct(v any) error` applies the following mapping:
| playground error type | xerrors code | rationale |
|------------------------------|-------------------|----------------------------------------------|
| `*InvalidValidationError` | `ErrInternal` | Passing a non-struct is a programming error |
| `ValidationErrors` (first) | `ErrInvalidInput` | Field constraint failures are user errors |
| any other non-nil error | `ErrInternal` | Unexpected; treated as internal |
Only the **first** `ValidationErrors` entry is surfaced. The full `playground.ValidationErrors` slice is attached as the wrapped error via `WithError(err)`, so callers who need all failures can use `errors.Unwrap` to reach `playground.ValidationErrors` and iterate themselves.
The `*xerrors.Err` for `ErrInvalidInput` includes two context fields set via `WithContext`:
- `"field"`: the struct field name (e.g. `"Email"`)
- `"tag"`: the failing rule (e.g. `"email"`, `"required"`)
These allow structured logging and API response builders to attach field-level detail without parsing the error message string.
## Consequences
- **Positive**: HTTP middleware can use `errors.As(err, &xe)` and switch on `xe.Code()` to produce `400 Bad Request` for `ErrInvalidInput` without any knowledge of the validation library.
- **Positive**: Structured context fields (`field`, `tag`) are available for logging and response serialization without string parsing.
- **Positive**: The full `ValidationErrors` slice is recoverable for callers that must report all failing fields (e.g., form validation responses).
- **Negative**: Only the first error is surfaced by default. Callers expecting a list of all failures in the top-level error must unwrap manually. This is a deliberate trade-off: the common case (return the first problem to the user) is easy; the less common case (return all problems) is possible but requires extra code.