From 328b80c06097d251bf85717f9845ed4f3e6819cf Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Wed, 18 Mar 2026 21:02:26 +0000 Subject: [PATCH] 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/ --- .devcontainer/devcontainer.json | 26 ++ .gitignore | 38 +++ CHANGELOG.md | 29 ++ CLAUDE.md | 78 +++++ LICENSE | 21 ++ README.md | 143 +++++++++ compliance_test.go | 10 + doc.go | 31 ++ .../ADR-001-playground-validator-backend.md | 27 ++ docs/adr/ADR-002-xerrors-integration.md | 39 +++ docs/adr/ADR-003-message-provider-i18n.md | 38 +++ go.mod | 18 ++ go.sum | 28 ++ messages.go | 61 ++++ valid.go | 79 +++++ valid_test.go | 289 ++++++++++++++++++ 16 files changed, 955 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 docs/adr/ADR-001-playground-validator-backend.md create mode 100644 docs/adr/ADR-002-xerrors-integration.md create mode 100644 docs/adr/ADR-003-message-provider-i18n.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 messages.go create mode 100644 valid.go create mode 100644 valid_test.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "features": { + "ghcr.io/devcontainers-extra/features/claude-code:1": {} + }, + "forwardPorts": [], + "postCreateCommand": "go version", + "customizations": { + "vscode": { + "settings": { + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + "explorer.compactFolders": false, + "explorer.showEmptyFolders": true + }, + "extensions": [ + "golang.go", + "eamodio.golang-postfix-completion", + "quicktype.quicktype", + "usernamehw.errorlens" + ] + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d1bc31 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0] - 2026-03-18 + +### Added + +- `Validator` interface with a single `Struct(v any) error` method for struct tag validation. +- `New(opts ...Option) Validator` constructor; returns an English-language validator by default. +- `Option` functional-option type for configuring a `Validator` at construction time. +- `WithMessageProvider(mp MessageProvider) Option` option to replace the built-in message provider. +- `MessageProvider` interface with `Message(field, tag, param string) string`; implement to supply custom or localised messages. +- `DefaultMessages` (`MessageProvider`): built-in English messages for `required`, `email`, `min`, `max`, and a generic fallback. +- `SpanishMessages` (`MessageProvider`): opt-in Spanish translations for the same tag set. +- Error behaviour: a failing struct field returns `*xerrors.Err` with code `ErrInvalidInput`; structured context keys `"field"` and `"tag"` are attached via `WithContext`. The raw `playground.ValidationErrors` is chained via `WithError` for callers that need all failures. +- Error behaviour: passing a non-struct value returns `*xerrors.Err` with code `ErrInternal` (wrapping `playground.InvalidValidationError`). +- Only the first failing field is surfaced per `Struct` call; the full error slice is preserved in the error chain. + +### Design Notes + +- `go-playground/validator/v10` is used as the backend but is entirely hidden behind the `Validator` interface; no playground types appear in the public API. +- `MessageProvider` decouples human-readable text from validation logic, enabling i18n without any changes to the core package. +- A single `*playground.Validate` instance is created per `Validator` (not per call) to amortise the cost of reflection-based type caching. + +[0.9.0]: https://code.nochebuena.dev/go/valid/releases/tag/v0.9.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a502190 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# valid + +Struct validation via go-playground/validator, returning xerrors-typed errors and supporting pluggable i18n messages. + +## Purpose + +Wraps `github.com/go-playground/validator/v10` behind a minimal `Validator` interface. Translates playground error types into `*xerrors.Err` values with stable `Code` values (`ErrInvalidInput`, `ErrInternal`), so HTTP middleware can map validation failures to HTTP status codes without knowing anything about the underlying library. + +## Tier & Dependencies + +**Tier 1** — depends on: +- `code.nochebuena.dev/go/xerrors` (Tier 0, error types) +- `github.com/go-playground/validator/v10` (external, hidden behind the interface) + +## Key Design Decisions + +- **Playground validator as hidden backend** (ADR-001): `*playground.Validate` is never exposed in the public API. Callers interact only with the `Validator` interface and `*xerrors.Err` errors. +- **xerrors integration** (ADR-002): `ValidationErrors` → `ErrInvalidInput`; `InvalidValidationError` (non-struct arg) → `ErrInternal`. Only the first failing field is surfaced; the full `playground.ValidationErrors` is attached via `WithError` for callers that need all failures. +- **MessageProvider for i18n** (ADR-003): Human-readable messages are delegated to a `MessageProvider` interface. `DefaultMessages` (English) is used automatically. `SpanishMessages` is opt-in. Custom providers are accepted via `WithMessageProvider`. + +## Patterns + +Default (English): + +```go +v := valid.New() +if err := v.Struct(req); err != nil { + // err is *xerrors.Err with Code() == xerrors.ErrInvalidInput +} +``` + +Spanish messages: + +```go +v := valid.New(valid.WithMessageProvider(valid.SpanishMessages)) +``` + +Custom message provider: + +```go +type myMessages struct{} +func (m myMessages) Message(field, tag, param string) string { ... } + +v := valid.New(valid.WithMessageProvider(myMessages{})) +``` + +Accessing structured context from the error: + +```go +var xe *xerrors.Err +if errors.As(err, &xe) { + field := xe.Fields()["field"] // e.g. "Email" + tag := xe.Fields()["tag"] // e.g. "email" +} +``` + +Accessing all validation errors: + +```go +var xe *xerrors.Err +errors.As(err, &xe) +var ve playground.ValidationErrors +errors.As(errors.Unwrap(xe), &ve) +// iterate ve for all failing fields +``` + +## What to Avoid + +- Do not expose `*playground.Validate` or `playground.ValidationErrors` from any new public function. Keep the backend hidden. +- Do not add generics to the `Validator` interface. The `Struct(v any) error` signature is intentionally non-generic; the playground library uses reflection internally. +- Do not register global custom validators on the shared `*playground.Validate` instance — that would break isolation between callers that share a process but expect different validation behaviour. +- Do not construct a new `*playground.Validate` per call; `New()` creates one instance per `Validator`. + +## Testing Notes + +- `valid_test.go` covers: `New()` defaults, custom `MessageProvider` injection, valid struct (nil error), `required`/`email`/`min`/`max` failures, non-struct input (`ErrInternal`), `Fields()` context keys, `Unwrap` to `playground.ValidationErrors`, Spanish messages, and all built-in message tags. +- `compliance_test.go` checks at compile time that `valid.New()` satisfies `Validator`, and that `DefaultMessages`/`SpanishMessages` satisfy `MessageProvider`. +- No network or database access — all tests are pure in-process. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d82fed4 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# `valid` + +> Struct validation backed by `go-playground/validator` with structured error output. + +**Module:** `code.nochebuena.dev/go/valid` +**Tier:** 1 — depends on `xerrors` (Tier 0) and `go-playground/validator/v10` +**Go:** 1.25+ +**Dependencies:** `code.nochebuena.dev/go/xerrors`, `github.com/go-playground/validator/v10` + +--- + +## Overview + +`valid` wraps [`go-playground/validator/v10`](https://github.com/go-playground/validator) and maps validation failures to structured [`*xerrors.Err`](../xerrors) values. It replaces the old `check` package, adding a plain constructor, a swappable message provider, and English messages by default. + +## Installation + +```sh +go get code.nochebuena.dev/go/valid +``` + +## Quick start + +```go +import "code.nochebuena.dev/go/valid" + +type CreateUserRequest struct { + Name string `validate:"required"` + Email string `validate:"required,email"` + Age int `validate:"min=18,max=120"` +} + +v := valid.New() + +err := v.Struct(req) +if err != nil { + // err is a *xerrors.Err with code ErrInvalidInput. + var xe *xerrors.Err + if errors.As(err, &xe) { + fmt.Println(xe.Code()) // INVALID_ARGUMENT + fmt.Println(xe.Message()) // "field 'Email' must be a valid email address" + fmt.Println(xe.Fields()) // map[field:Email tag:email] + } +} +``` + +## Usage + +### Creating a validator + +```go +// English messages (default). +v := valid.New() + +// Spanish messages. +v := valid.New(valid.WithMessageProvider(valid.SpanishMessages)) + +// Custom message provider. +v := valid.New(valid.WithMessageProvider(myProvider)) +``` + +### Validating structs + +```go +err := v.Struct(myStruct) +``` + +| Outcome | Error returned | +|---------|---------------| +| All fields pass | `nil` | +| Field constraint failure | `*xerrors.Err` with `ErrInvalidInput`, first error only | +| Not a struct | `*xerrors.Err` with `ErrInternal` | + +The returned `*xerrors.Err` for field failures carries: +- `Fields()["field"]` — the failing struct field name +- `Fields()["tag"]` — the failing validation rule (e.g. `"email"`, `"required"`) +- `Unwrap()` — the underlying `validator.ValidationErrors` + +### Message providers + +`MessageProvider` maps a validation failure to a human-readable message: + +```go +type MessageProvider interface { + Message(field, tag, param string) string +} +``` + +Built-in presets: + +| Variable | Language | Usage | +|----------|----------|-------| +| `DefaultMessages` | English | automatic (no option needed) | +| `SpanishMessages` | Spanish | `WithMessageProvider(valid.SpanishMessages)` | + +Custom provider example: + +```go +type myMessages struct{} + +func (m myMessages) Message(field, tag, param string) string { + switch tag { + case "required": + return field + " is mandatory" + default: + return field + " failed: " + tag + } +} + +v := valid.New(valid.WithMessageProvider(myMessages{})) +``` + +## Design decisions + +**No singleton** — `valid.New(opts...)` returns a plain value. Multiple validators with different configurations can coexist. Tests create isolated instances without global state. + +**Only the first validation error is surfaced** — `go-playground/validator` returns all field errors at once; we surface only the first for API simplicity. Apps needing all failures can cast `errors.Unwrap(err)` to `validator.ValidationErrors`: + +```go +var xe *xerrors.Err +errors.As(err, &xe) + +var ve validator.ValidationErrors +errors.As(errors.Unwrap(xe), &ve) // all field errors +``` + +**`valid` imports `xerrors` directly** — `valid` is Tier 1 and `xerrors` is Tier 0. The import is intentional; `valid` constructs `*xerrors.Err` values. Duck-typing is reserved for cases where the import would create a circular or cross-tier dependency. + +**Spanish is bundled, not a separate module** — the Spanish preset is a small, zero-dep addition. Splitting it into a separate module would add publish overhead for negligible gain. + +## Ecosystem + +``` +Tier 0: xerrors + ↑ (direct import — constructs *xerrors.Err) +Tier 1: valid ← you are here + ↑ +Tier 2: httputil (injects valid.Validator into generic handler) +``` + +## License + +MIT diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..4c1c4a4 --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,10 @@ +package valid_test + +import "code.nochebuena.dev/go/valid" + +// Verify New returns a Validator. +var _ valid.Validator = valid.New() + +// Verify DefaultMessages and SpanishMessages satisfy MessageProvider. +var _ valid.MessageProvider = valid.DefaultMessages +var _ valid.MessageProvider = valid.SpanishMessages diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..4e64c32 --- /dev/null +++ b/doc.go @@ -0,0 +1,31 @@ +// Package valid provides struct validation backed by [github.com/go-playground/validator/v10]. +// +// Create a validator with [New]: +// +// v := valid.New() +// +// Validate a struct: +// +// type CreateUserRequest struct { +// Name string `validate:"required"` +// Email string `validate:"required,email"` +// Age int `validate:"min=18,max=120"` +// } +// +// err := v.Struct(req) +// if err != nil { +// // err is a *xerrors.Err with code ErrInvalidInput. +// // Use errors.As to inspect it. +// } +// +// Use Spanish messages: +// +// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages)) +// +// Use a custom message provider: +// +// type myMessages struct{} +// func (m myMessages) Message(field, tag, param string) string { ... } +// +// v := valid.New(valid.WithMessageProvider(myMessages{})) +package valid diff --git a/docs/adr/ADR-001-playground-validator-backend.md b/docs/adr/ADR-001-playground-validator-backend.md new file mode 100644 index 0000000..ff6ca31 --- /dev/null +++ b/docs/adr/ADR-001-playground-validator-backend.md @@ -0,0 +1,27 @@ +# ADR-001: go-playground/validator as Hidden Backend + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +Struct validation via struct tags is a well-understood pattern in Go. `github.com/go-playground/validator/v10` is the de-facto standard library for this, supporting a large rule set (`required`, `email`, `min`, `max`, `url`, `uuid`, and hundreds more) and nested struct traversal. + +However, directly using the playground `*validate.Validate` type throughout application code creates a hard dependency: its error types (`ValidationErrors`, `InvalidValidationError`) must be imported wherever errors are inspected, and its configuration API leaks into all call sites. + +## Decision + +`github.com/go-playground/validator/v10` is used as the sole validation backend but is not exposed in the public API. + +The public API is the `Validator` interface, which has one method: `Struct(v any) error`. The concrete type `validator` (unexported) holds a `*playground.Validate` instance. The playground package is imported under the alias `playground` to make it unambiguous at a glance which types originate from it. + +Callers never see `playground.ValidationErrors` or `*playground.InvalidValidationError` directly. The concrete error types are translated to `*xerrors.Err` inside `Struct()` before being returned. + +The playground `*Validate` instance is created once inside `New()` with default options. No custom validators, tag name functions, or struct-level validators are registered. This keeps the API surface small and the behaviour predictable. + +## Consequences + +- **Positive**: The playground library can be upgraded or replaced without changing any call-site code — only the `valid` package internals change. +- **Positive**: Application code only needs to import `code.nochebuena.dev/go/valid` and `code.nochebuena.dev/go/xerrors`; no direct dependency on `go-playground/validator`. +- **Negative**: Advanced playground features (custom validators, `RegisterTagNameFunc`, cross-field validation) are not accessible without extending the `valid` package. If a project needs them, it should add `Option` functions or methods to `Validator`. +- **Note**: The playground alias `playground "github.com/go-playground/validator/v10"` is retained in the source as a readability aid, not a requirement. It prevents confusion with the package name `valid` used in the surrounding code. diff --git a/docs/adr/ADR-002-xerrors-integration.md b/docs/adr/ADR-002-xerrors-integration.md new file mode 100644 index 0000000..a5aab9b --- /dev/null +++ b/docs/adr/ADR-002-xerrors-integration.md @@ -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. diff --git a/docs/adr/ADR-003-message-provider-i18n.md b/docs/adr/ADR-003-message-provider-i18n.md new file mode 100644 index 0000000..76bb756 --- /dev/null +++ b/docs/adr/ADR-003-message-provider-i18n.md @@ -0,0 +1,38 @@ +# ADR-003: MessageProvider Pattern for i18n + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +Validation error messages shown to end users must be human-readable. Applications targeting different locales need messages in different languages. Hardcoding English messages inside the `Validator` implementation would make internationalization impossible without forking the package. + +At the same time, the majority of applications use a single language throughout their lifetime. Requiring every caller to configure a message provider would be boilerplate-heavy for the common case. + +## Decision + +A `MessageProvider` interface is defined: + +```go +type MessageProvider interface { + Message(field, tag, param string) string +} +``` + +`Message` receives the failing field name, the rule tag, and the rule parameter (e.g., `"5"` for `min=5`, or `""` if none), and returns a human-readable string. + +Two built-in implementations are provided as package-level variables: +- `DefaultMessages` — English, used automatically when no option is passed to `New()`. +- `SpanishMessages` — Spanish, available as an opt-in via `WithMessageProvider(valid.SpanishMessages)`. + +Custom implementations are supported by passing any value that satisfies `MessageProvider` to `WithMessageProvider`. + +The `New()` constructor defaults to `DefaultMessages` and applies options via a `config` struct, following the functional options pattern. This means zero boilerplate for the common (English) case and a single option call for overrides. + +## Consequences + +- **Positive**: English is the zero-configuration default — `valid.New()` requires no arguments. +- **Positive**: Spanish is available without any external dependency — just `valid.SpanishMessages`. +- **Positive**: Applications can supply their own `MessageProvider` for any other language or for message formats that include the failing value, link to docs, etc. +- **Negative**: The built-in providers handle only four tags (`required`, `email`, `min`, `max`) explicitly; all others fall through to a generic fallback message. Applications using many custom tags should supply a custom provider. +- **Note**: Message formatting uses the struct field name as returned by `go-playground/validator` (the Go field name, e.g. `"Email"`), not a JSON tag. If user-facing messages must show the JSON key name, a custom `MessageProvider` combined with a registered tag name function on the playground validator would be needed. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..344b30a --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module code.nochebuena.dev/go/valid + +go 1.25 + +require ( + code.nochebuena.dev/go/xerrors v0.9.0 + github.com/go-playground/validator/v10 v10.30.1 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7488def --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE= +code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/messages.go b/messages.go new file mode 100644 index 0000000..a799bcf --- /dev/null +++ b/messages.go @@ -0,0 +1,61 @@ +package valid + +import "fmt" + +// MessageProvider maps a validation failure to a human-readable message. +// +// - field: the struct field name (e.g. "Email") +// - tag: the failing validation rule (e.g. "required", "email", "min") +// - param: the rule parameter if any (e.g. "18" for min=18), or "" if none +type MessageProvider interface { + Message(field, tag, param string) string +} + +// DefaultMessages is the built-in English message provider. +// Used automatically when no WithMessageProvider option is given. +var DefaultMessages MessageProvider = defaultMessages{} + +// SpanishMessages is an opt-in Spanish message provider. +var SpanishMessages MessageProvider = spanishMessages{} + +// --------------------------------------------------------------- +// English (default) +// --------------------------------------------------------------- + +type defaultMessages struct{} + +func (defaultMessages) Message(field, tag, param string) string { + switch tag { + case "required": + return fmt.Sprintf("field '%s' is required", field) + case "email": + return fmt.Sprintf("field '%s' must be a valid email address", field) + case "min": + return fmt.Sprintf("field '%s' is too short (minimum %s)", field, param) + case "max": + return fmt.Sprintf("field '%s' is too long (maximum %s)", field, param) + default: + return fmt.Sprintf("field '%s' failed validation rule '%s'", field, tag) + } +} + +// --------------------------------------------------------------- +// Spanish (opt-in) +// --------------------------------------------------------------- + +type spanishMessages struct{} + +func (spanishMessages) Message(field, tag, param string) string { + switch tag { + case "required": + return fmt.Sprintf("El campo '%s' es obligatorio", field) + case "email": + return fmt.Sprintf("El campo '%s' debe ser un correo electrónico válido", field) + case "min": + return fmt.Sprintf("El campo '%s' es demasiado corto (mínimo %s)", field, param) + case "max": + return fmt.Sprintf("El campo '%s' es demasiado largo (máximo %s)", field, param) + default: + return fmt.Sprintf("Error en el campo '%s': regla '%s' no cumplida", field, tag) + } +} diff --git a/valid.go b/valid.go new file mode 100644 index 0000000..f0aaf4f --- /dev/null +++ b/valid.go @@ -0,0 +1,79 @@ +package valid + +import ( + "errors" + + "code.nochebuena.dev/go/xerrors" + playground "github.com/go-playground/validator/v10" +) + +// Validator validates structs using struct tags. +type Validator interface { + // Struct validates v and returns a *xerrors.Err if validation fails. + // Returns nil if v is valid. + // Returns ErrInvalidInput for field constraint failures (first error only). + // Returns ErrInternal if v is not a struct. + Struct(v any) error +} + +// Option configures a Validator. +type Option func(*config) + +// config holds constructor configuration. +type config struct { + mp MessageProvider +} + +// WithMessageProvider sets a custom MessageProvider. +// Default: DefaultMessages (English). +func WithMessageProvider(mp MessageProvider) Option { + return func(c *config) { + c.mp = mp + } +} + +// New returns a Validator. Without options, DefaultMessages (English) is used. +func New(opts ...Option) Validator { + cfg := &config{mp: DefaultMessages} + for _, o := range opts { + o(cfg) + } + return &validator{ + v: playground.New(), + mp: cfg.mp, + } +} + +// validator is the concrete implementation of Validator. +type validator struct { + v *playground.Validate + mp MessageProvider +} + +// Struct implements Validator. +// +// Only the first validation error is surfaced. Apps needing all failures can +// cast errors.Unwrap(err) to validator.ValidationErrors themselves. +func (val *validator) Struct(v any) error { + err := val.v.Struct(v) + if err == nil { + return nil + } + + var invalidErr *playground.InvalidValidationError + if errors.As(err, &invalidErr) { + return xerrors.Wrap(xerrors.ErrInternal, "internal validation error", err) + } + + var validationErrs playground.ValidationErrors + if errors.As(err, &validationErrs) { + first := validationErrs[0] + msg := val.mp.Message(first.Field(), first.Tag(), first.Param()) + return xerrors.New(xerrors.ErrInvalidInput, msg). + WithContext("field", first.Field()). + WithContext("tag", first.Tag()). + WithError(err) + } + + return xerrors.Wrap(xerrors.ErrInternal, "internal validation error", err) +} diff --git a/valid_test.go b/valid_test.go new file mode 100644 index 0000000..f3db0ff --- /dev/null +++ b/valid_test.go @@ -0,0 +1,289 @@ +package valid + +import ( + "errors" + "strings" + "testing" + + "code.nochebuena.dev/go/xerrors" + playground "github.com/go-playground/validator/v10" +) + +type testUser struct { + Name string `validate:"required"` + Email string `validate:"required,email"` + Age int `validate:"min=18,max=120"` +} + +// --------------------------------------------------------------- +// Constructor tests +// --------------------------------------------------------------- + +func TestNew_Defaults(t *testing.T) { + v := New() + if v == nil { + t.Fatal("New() returned nil") + } +} + +func TestNew_WithMessageProvider(t *testing.T) { + called := false + mp := &testMP{fn: func(field, tag, param string) string { + called = true + return "custom: " + field + }} + + v := New(WithMessageProvider(mp)) + _ = v.Struct(testUser{}) // triggers validation failure (missing Name/Email) + if !called { + t.Error("custom MessageProvider was not called") + } +} + +// --------------------------------------------------------------- +// Struct validation tests +// --------------------------------------------------------------- + +func TestValidator_Struct_Valid(t *testing.T) { + v := New() + u := testUser{Name: "Alice", Email: "alice@example.com", Age: 25} + if err := v.Struct(u); err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestValidator_Struct_MissingRequired(t *testing.T) { + v := New() + u := testUser{Email: "a@b.com", Age: 20} // Name is missing + err := v.Struct(u) + if err == nil { + t.Fatal("expected error, got nil") + } + + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("expected *xerrors.Err, got %T", err) + } + if xe.Code() != xerrors.ErrInvalidInput { + t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInvalidInput) + } + if xe.Fields()["field"] != "Name" { + t.Errorf("field = %v, want Name", xe.Fields()["field"]) + } +} + +func TestValidator_Struct_InvalidEmail(t *testing.T) { + v := New() + u := testUser{Name: "Bob", Email: "not-an-email", Age: 25} + err := v.Struct(u) + if err == nil { + t.Fatal("expected error, got nil") + } + + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("expected *xerrors.Err, got %T", err) + } + if xe.Fields()["tag"] != "email" { + t.Errorf("tag = %v, want email", xe.Fields()["tag"]) + } +} + +func TestValidator_Struct_BelowMin(t *testing.T) { + v := New() + u := testUser{Name: "Carol", Email: "carol@example.com", Age: 10} + err := v.Struct(u) + if err == nil { + t.Fatal("expected error, got nil") + } + + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("expected *xerrors.Err, got %T", err) + } + if xe.Code() != xerrors.ErrInvalidInput { + t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInvalidInput) + } +} + +func TestValidator_Struct_AboveMax(t *testing.T) { + v := New() + u := testUser{Name: "Dave", Email: "dave@example.com", Age: 200} + err := v.Struct(u) + if err == nil { + t.Fatal("expected error, got nil") + } + + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("expected *xerrors.Err, got %T", err) + } + if xe.Code() != xerrors.ErrInvalidInput { + t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInvalidInput) + } +} + +func TestValidator_Struct_NotAStruct(t *testing.T) { + v := New() + err := v.Struct("not a struct") + if err == nil { + t.Fatal("expected error, got nil") + } + + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("expected *xerrors.Err, got %T", err) + } + if xe.Code() != xerrors.ErrInternal { + t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInternal) + } +} + +func TestValidator_Struct_ErrorCode(t *testing.T) { + v := New() + u := testUser{} // all fields fail + err := v.Struct(u) + + var xe *xerrors.Err + if !errors.As(err, &xe) { + t.Fatalf("errors.As failed: %T", err) + } + if xe.Code() != xerrors.ErrInvalidInput { + t.Errorf("Code() = %s, want %s", xe.Code(), xerrors.ErrInvalidInput) + } +} + +func TestValidator_Struct_Fields(t *testing.T) { + v := New() + u := testUser{} // missing Name triggers required + err := v.Struct(u) + + var xe *xerrors.Err + errors.As(err, &xe) + fields := xe.Fields() + if _, ok := fields["field"]; !ok { + t.Error("Fields() missing 'field' key") + } + if _, ok := fields["tag"]; !ok { + t.Error("Fields() missing 'tag' key") + } +} + +func TestValidator_Struct_Unwrap(t *testing.T) { + v := New() + u := testUser{} // triggers validation error + err := v.Struct(u) + + var xe *xerrors.Err + errors.As(err, &xe) + + // The wrapped error must be a playground.ValidationErrors. + cause := errors.Unwrap(xe) + if cause == nil { + t.Fatal("Unwrap returned nil") + } + var ve playground.ValidationErrors + if !errors.As(cause, &ve) { + t.Errorf("Unwrap should return validator.ValidationErrors, got %T", cause) + } +} + +func TestValidator_Struct_SpanishMessages(t *testing.T) { + v := New(WithMessageProvider(SpanishMessages)) + u := testUser{Email: "a@b.com", Age: 20} // missing Name + + err := v.Struct(u) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "obligatorio") { + t.Errorf("expected Spanish message, got: %s", err.Error()) + } +} + +func TestValidator_Struct_EnglishMessage_Required(t *testing.T) { + v := New() + u := testUser{Email: "a@b.com", Age: 20} // missing Name + + err := v.Struct(u) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected English 'required' message, got: %s", err.Error()) + } +} + +// --------------------------------------------------------------- +// MessageProvider tests +// --------------------------------------------------------------- + +func TestDefaultMessages_AllTags(t *testing.T) { + mp := DefaultMessages + tags := []struct { + tag string + param string + }{ + {"required", ""}, + {"email", ""}, + {"min", "5"}, + {"max", "100"}, + {"unknown_rule", ""}, + } + for _, tt := range tags { + msg := mp.Message("Field", tt.tag, tt.param) + if msg == "" { + t.Errorf("DefaultMessages.Message(%q) returned empty string", tt.tag) + } + } +} + +func TestSpanishMessages_AllTags(t *testing.T) { + mp := SpanishMessages + tags := []struct { + tag string + param string + }{ + {"required", ""}, + {"email", ""}, + {"min", "5"}, + {"max", "100"}, + {"unknown_rule", ""}, + } + for _, tt := range tags { + msg := mp.Message("Campo", tt.tag, tt.param) + if msg == "" { + t.Errorf("SpanishMessages.Message(%q) returned empty string", tt.tag) + } + } +} + +func TestWithMessageProvider_CustomImpl(t *testing.T) { + mp := &testMP{fn: func(field, tag, param string) string { + return "CUSTOM:" + field + ":" + tag + }} + v := New(WithMessageProvider(mp)) + u := testUser{Email: "a@b.com", Age: 20} // missing Name + + err := v.Struct(u) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "CUSTOM:") { + t.Errorf("expected custom message prefix, got: %s", err.Error()) + } +} + +// --------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------- + +type testMP struct { + fn func(field, tag, param string) string +} + +func (m *testMP) Message(field, tag, param string) string { + return m.fn(field, tag, param) +}