diff --git a/README.md b/README.md index 9927118..ecaee11 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,93 @@ And when each key is validated, its rules are also evaluated in the order they a If a rule fails, an error is recorded for that key, and the validation will continue with the next key. +### Validating Unions (one-of) + +Sometimes a value is valid if it matches *one of* several shapes — a discriminated union. A login +payload, for example, might be `{email, password}` **or** `{username, password}`, but never both. The +`MatchOneOf` family validates a value against a set of alternative schemas: the first schema that +fully validates wins, and its index is returned so you can act on the matched shape (parse it, merge +it, branch on it). If none match, a `OneOfError` is returned describing every alternative that was +tried. + +#### Maps + +A map schema is just a `Map(...)` rule, and a variant forbids a field simply by **not listing it** — +`Map` already reports unlisted keys as "key not expected" (unless you call `AllowExtraKeys`), so no +special "forbidden" rule is needed. + +```go +withEmail := validation.Map( + validation.Key("email", validation.Required, is.EmailFormat), + validation.Key("password", validation.Required), +) +withUsername := validation.Map( + validation.Key("username", validation.Required), + validation.Key("password", validation.Required), +) + +i, err := validation.MatchOneOf(input, withEmail, withUsername) +// i == 0 -> matched withEmail +// i == 1 -> matched withUsername +// i == -1 -> err is a OneOfError describing both attempts +``` + +Use `MatchOneOfWithContext` to thread a `context.Context` through to the schema rules. + +#### Structs + +In a struct every field always exists (as its zero value), so a variant marks the fields it forbids +with the `Never` rule. `MatchOneOfStruct` takes a pointer to the struct and one `[]*FieldRules` per +variant: + +```go +emailLogin := []*validation.FieldRules{ + validation.Field(&l.Email, validation.Required, is.EmailFormat), + validation.Field(&l.Password, validation.Required), + validation.Field(&l.Username, validation.Never), // forbidden in this variant +} +usernameLogin := []*validation.FieldRules{ + validation.Field(&l.Email, validation.Never), + validation.Field(&l.Username, validation.Required), + validation.Field(&l.Password, validation.Required), +} + +i, err := validation.MatchOneOfStruct(ctx, &l, emailLogin, usernameLogin) +``` + +`Never` gives pointer fields nil semantics (a present pointer fails even if it points at an empty +value) and non-pointer fields zero-value semantics (an empty string or `0` passes). + +#### The `OneOf` rule and strict matching + +When you only need pass/fail and not the winning index, `OneOf(schemas ...Rule)` is the rule form and +composes anywhere a `Rule` is accepted: + +```go +err := validation.Validate(input, validation.OneOf(withEmail, withUsername)) +``` + +By default the first matching schema wins (`anyOf` semantics). Call `.Strict()` to require that +*exactly* one schema match; if more than one does, it fails with `ErrAmbiguousMatch` — a useful guard +against schemas that are not mutually exclusive. + +#### The error shape + +A failed union produces a `OneOfError`, which marshals to JSON as a single `oneOf` field whose value +is an array of the per-variant error maps, one entry per schema, in order. Each entry contains the +fields that failed for that variant: + +```go +b, _ := json.Marshal(err) +fmt.Println(string(b)) +// {"oneOf":[{"username":"must not be provided"},{"email":"must not be provided"}]} +``` + +Because `OneOfError` is returned unwrapped, it nests correctly inside a parent `validation.Errors` +(for example when a union is one field of a larger object), serializing structurally rather than +collapsing to a string. + + ### Validation Errors The `validation.ValidateStruct` method returns validation errors found in struct fields in terms of `validation.Errors` @@ -686,6 +773,9 @@ so you can write `validation.In("a", "b")` or `validation.Min(10)` without spell * `NilOrNotEmpty`: checks if a value is a nil pointer or a non-empty value. This differs from `Required` in that it treats a nil pointer as valid. * `Nil`: checks if a value is a nil pointer. * `Empty`: checks if a value is empty. nil pointers are considered valid. +* `Never`: checks that a value is absent — a nil pointer/interface, or the zero value for any other + type. Use it on the struct fields that a discriminated-union variant forbids (the counterpart to + `Required`). Unlike `Empty`, a *present* pointer fails even when it references an empty value. **Composition and control flow** @@ -694,6 +784,17 @@ so you can write `validation.In("a", "b")` or `validation.Min(10)` without spell * `Else(rules ...Rule)`: must be used with `When(condition, rules ...Rule)`, validates with the specified rules only when the condition is false. * `Skip`: this is a special rule used to indicate that all rules following it should be skipped (including the nested ones). +**Unions (one-of)** + +* `OneOf(schemas ...Rule)`: passes when the value matches at least one of the schemas (the first + match wins). Call `.Strict()` to require exactly one match. On failure the error is a `OneOfError` + that marshals to `{"oneOf": [...]}`. +* `MatchOneOf(value, schemas ...Rule) (int, error)` / `MatchOneOfWithContext`: like `OneOf` but + returns the index of the matching schema so you can act on the matched shape. Schemas are typically + `Map(...)` rules; a variant forbids a key by omitting it. +* `MatchOneOfStruct[T](ctx, *T, schemas ...[]*FieldRules) (int, error)`: the struct counterpart, + taking one `[]*FieldRules` per variant. Use `Never` to forbid the fields a variant disallows. + The `is` sub-package provides a list of commonly used string validation rules that can be used to check if the format of a value satisfies certain requirements. Note that these rules only handle strings and byte slices and if a string or byte slice is empty, it is considered valid. You may use a `Required` rule to ensure a value is not empty. diff --git a/example_union_test.go b/example_union_test.go new file mode 100644 index 0000000..52ae817 --- /dev/null +++ b/example_union_test.go @@ -0,0 +1,142 @@ +// Copyright 2016 Qiang Xue. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package validation_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/monetr/validation" + "github.com/monetr/validation/is" +) + +// This file reproduces monetr's server/datasources/table.FieldRef — a reference +// to a column in an imported document that is EITHER a named field OR a derived +// (computed) field, never both and never neither — to show the union API end to +// end against realistic input, in both its struct and map forms. + +// importColumns stands in for the columns monetr pulls from the upload context +// via getColumns(ctx). +var importColumns = []string{"date", "amount", "description"} + +const derivedKindRowNumber = "rowNumber" + +// fieldRef mirrors table.FieldRef. +type fieldRef struct { + Name string `json:"name,omitempty"` + DerivedKind string `json:"derivedKind,omitempty"` +} + +// Validate is the polished, library-native version of FieldRef.Validate. It +// returns the index of the matched variant so a caller could branch on which +// shape was supplied. +// +// monetr writes this today as: +// +// return validators.OneOfStruct(ctx, s, +// []*validation.FieldRules{ +// validation.Field(&s.Name, validators.In(getColumns(ctx)...), validators.PrintableUnicode, validation.Required), +// validation.Field(&s.DerivedKind, validation.Empty), +// }, +// []*validation.FieldRules{ +// validation.Field(&s.Name, validation.Empty), +// validation.Field(&s.DerivedKind, validators.In(DerivedKindRowNumber), validation.Required), +// }, +// ) +// +// The differences: it returns (int, error) so the matched variant is known, the +// forbidden field uses Never (clearer "must not be provided" message and proper +// pointer semantics) instead of Empty, and the OneOfError it returns is already +// JSON-ready — no MarshalErrorTree walker required. +func (s *fieldRef) Validate(ctx context.Context) (int, error) { + return validation.MatchOneOfStruct( + ctx, + s, + // Variant 0 — a named column: Name is required and must be a known + // column; DerivedKind is forbidden. + []*validation.FieldRules{ + validation.Field(&s.Name, + validation.Required, + validation.In(importColumns...), + is.PrintableUnicode, + ), + validation.Field(&s.DerivedKind, validation.Never), + }, + // Variant 1 — a derived field: DerivedKind is required and must be a + // known kind; Name is forbidden. + []*validation.FieldRules{ + validation.Field(&s.Name, validation.Never), + validation.Field(&s.DerivedKind, + validation.Required, + validation.In(derivedKindRowNumber), + ), + }, + ) +} + +func TestUnionExample_FieldRefStruct(t *testing.T) { + ctx := context.Background() + inputs := []fieldRef{ + {Name: "amount"}, // valid: a named column + {DerivedKind: "rowNumber"}, // valid: a derived field + {Name: "amount", DerivedKind: "rowNumber"}, // invalid: both supplied + {}, // invalid: neither supplied + {Name: "not_a_column"}, // invalid: unknown column + } + + fmt.Println("=== struct form (MatchOneOfStruct + Never) ===") + for _, in := range inputs { + ref := in + raw, _ := json.Marshal(in) + i, err := ref.Validate(ctx) + if err == nil { + fmt.Printf("input %-46s => matched variant %d\n", raw, i) + continue + } + out, _ := json.Marshal(err) + fmt.Printf("input %-46s => %s\n", raw, out) + } +} + +func TestUnionExample_RequestMap(t *testing.T) { + // The same union as a map of dynamic request data, mirroring how monetr's + // controller.parse[T] decodes a JSON body into map[string]any and validates + // it. In the map form a variant forbids a field simply by NOT listing it: + // MapRule reports the unexpected key on its own, so Never is not involved. + namedColumn := validation.Map( + validation.Key("name", validation.Required, validation.In(importColumns...), is.PrintableUnicode), + // "derivedKind" intentionally omitted -> forbidden in this variant. + ) + derivedField := validation.Map( + // "name" intentionally omitted -> forbidden in this variant. + validation.Key("derivedKind", validation.Required, validation.In(derivedKindRowNumber)), + ) + + inputs := []map[string]any{ + {"name": "amount"}, + {"derivedKind": "rowNumber"}, + {"name": "amount", "derivedKind": "rowNumber"}, + {"name": "not_a_column"}, + } + + fmt.Println("\n=== map form (MatchOneOf, forbid by omitting the key) ===") + for _, in := range inputs { + raw, _ := json.Marshal(in) + i, err := validation.MatchOneOf(in, namedColumn, derivedField) + if err == nil { + fmt.Printf("input %-50s => matched variant %d\n", raw, i) + continue + } + // This is the entire controller response body now: because OneOfError + // marshals itself, "problems" is just the error — no MarshalErrorTree. + response, _ := json.Marshal(map[string]any{ + "error": "Invalid request", + "problems": err, + }) + fmt.Printf("input %-50s => %s\n", raw, response) + } +} diff --git a/never.go b/never.go new file mode 100644 index 0000000..9cc2306 --- /dev/null +++ b/never.go @@ -0,0 +1,79 @@ +// Copyright 2016 Qiang Xue. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package validation + +import "reflect" + +// ErrNever is the error returned by [Never] when a value that must be absent is +// instead present with a meaningful value. +var ErrNever = NewError("validation_never", "must not be provided") + +// Never is a validation rule that asserts a value is absent. It is the +// discriminated-union counterpart to [Required]: use it on the struct fields +// that a given variant forbids. +// +// A struct field always exists (it is present as its zero value), so Never +// infers "absent" from the value itself: +// +// - pointer or interface: must be nil. A non-nil pointer fails even when it +// references an empty value, because a present pointer is a provided value. +// - any other kind: must be the zero value (empty string, 0, false, empty +// slice/map/array, the zero time.Time). A meaningful value fails. +// +// This auto-switching is what distinguishes Never from the existing absence +// rules: [Empty] dereferences first, so it would let a pointer to an empty +// value pass, while [Nil] would reject a non-pointer zero value. Never gives +// pointers Nil semantics and everything else Empty semantics in one rule. +// +// Never is value-based and intended for struct fields validated with [Field]. +// It is deliberately not offered for map keys: a [Map] schema already reports +// keys it does not list as [ErrKeyUnexpected] (unless [MapRule.AllowExtraKeys] +// is set), so a map union forbids a key simply by omitting it from the variant +// that disallows it. Using Never as a map value rule would be a mistake — it +// would let a present-but-empty value pass when, in a map, the key's mere +// presence should fail. +var Never = neverRule{} + +type neverRule struct { + err Error +} + +// Validate checks that the value is absent: a nil pointer/interface, or the +// zero value for any other kind. +func (r neverRule) Validate(value any) error { + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Invalid: + // An untyped nil is absent. + return nil + case reflect.Ptr, reflect.Interface: + if rv.IsNil() { + return nil + } + default: + if IsEmpty(value) { + return nil + } + } + if r.err != nil { + return r.err + } + return ErrNever +} + +// Error sets the error message for the rule. +func (r neverRule) Error(message string) neverRule { + if r.err == nil { + r.err = ErrNever + } + r.err = r.err.SetMessage(message) + return r +} + +// ErrorObject sets the error struct for the rule. +func (r neverRule) ErrorObject(err Error) neverRule { + r.err = err + return r +} diff --git a/never_test.go b/never_test.go new file mode 100644 index 0000000..ef571d6 --- /dev/null +++ b/never_test.go @@ -0,0 +1,100 @@ +// Copyright 2016 Qiang Xue. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNever(t *testing.T) { + str := "hello" + empty := "" + zero := 0 + nonZero := 5 + var nilStr *string + + tests := []struct { + tag string + value any + err string + }{ + {"untyped-nil", nil, ""}, + {"empty-string", "", ""}, + {"nonempty-string", "hello", "must not be provided"}, + {"zero-int", 0, ""}, + {"nonzero-int", 5, "must not be provided"}, + {"false-bool", false, ""}, + {"true-bool", true, "must not be provided"}, + {"empty-slice", []int{}, ""}, + {"nonempty-slice", []int{1}, "must not be provided"}, + {"empty-map", map[string]int{}, ""}, + {"nonempty-map", map[string]int{"a": 1}, "must not be provided"}, + {"nil-pointer", nilStr, ""}, + // A present pointer is a provided value and fails even when it references + // an empty value: pointers get Nil semantics, not Empty semantics. + {"pointer-to-nonempty", &str, "must not be provided"}, + {"pointer-to-empty-string", &empty, "must not be provided"}, + {"pointer-to-zero-int", &zero, "must not be provided"}, + {"pointer-to-nonzero-int", &nonZero, "must not be provided"}, + } + + for _, test := range tests { + err := Never.Validate(test.value) + assertError(t, test.err, err, test.tag) + } +} + +func TestNeverRule_Error(t *testing.T) { + r := Never + assert.Equal(t, "must not be provided", r.Validate("x").Error()) + + r2 := r.Error("nope") + // The original rule is unchanged (value receiver). + assert.Equal(t, "must not be provided", r.Validate("x").Error()) + assert.Equal(t, "nope", r2.Validate("x").Error()) +} + +func TestNeverRule_ErrorObject(t *testing.T) { + r := Never + err := NewError("code", "abc") + r = r.ErrorObject(err) + + assert.Equal(t, err, r.err) + assert.Equal(t, "abc", r.Validate("x").Error()) + assert.NotEqual(t, err, Never.err) +} + +// TestNever_InStructField exercises Never the way it is meant to be used: as a +// rule on a struct field that a variant forbids. +func TestNever_InStructField(t *testing.T) { + type payload struct { + Name string `json:"name"` + Extra *string `json:"extra"` + } + + // Non-pointer zero value and nil pointer both pass. + p := payload{Name: "", Extra: nil} + err := ValidateStruct(&p, + Field(&p.Name, Never), + Field(&p.Extra, Never), + ) + assert.NoError(t, err) + + // A provided value on either field fails, keyed by the json tag. + provided := "x" + p = payload{Name: "supplied", Extra: &provided} + err = ValidateStruct(&p, + Field(&p.Name, Never), + Field(&p.Extra, Never), + ) + if assert.Error(t, err) { + errs, ok := err.(Errors) + assert.True(t, ok) + assert.Equal(t, "must not be provided", errs["name"].Error()) + assert.Equal(t, "must not be provided", errs["extra"].Error()) + } +} diff --git a/oneof.go b/oneof.go new file mode 100644 index 0000000..85301a7 --- /dev/null +++ b/oneof.go @@ -0,0 +1,229 @@ +// Copyright 2016 Qiang Xue. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package validation + +import ( + "context" + "encoding/json" + "strings" +) + +var ( + _ error = OneOfError{} + _ json.Marshaler = OneOfError{} + + // ErrAmbiguousMatch is returned by a strict union (see [OneOfRule.Strict]) + // when more than one alternative schema validates. It indicates the schemas + // are not mutually exclusive. + ErrAmbiguousMatch = NewError( + "validation_oneof_ambiguous", + "must match exactly one of the allowed shapes, but matched several", + ) +) + +// OneOfError is the error returned when a value matches none of the alternative +// schemas of a union ([MatchOneOf], [OneOf] or [MatchOneOfStruct]). Entries are +// in schema order; each is normally the [Errors] produced by one schema +// attempt, though an entry may be any error (for example a nested OneOfError, +// or a scalar rule's error when a schema is a single rule rather than a +// map/struct shape). +// +// It marshals to JSON as +// +// {"oneOf": [, , ...]} +// +// so a client can see each shape the input could have matched. A union with a +// single alternative still marshals as a one-element oneOf array — the shape is +// uniform regardless of how many alternatives there are. +// +// OneOfError is returned unwrapped (it is not boxed in another error type), so +// when it sits as a value inside a parent [Errors], [Errors.MarshalJSON] +// serializes it structurally instead of collapsing it to a string. +type OneOfError []error + +// Error implements [error]. +func (e OneOfError) Error() string { + if len(e) == 0 { + return "" + } + parts := make([]string, len(e)) + for i, err := range e { + parts[i] = "(" + err.Error() + ")" + } + return "must match one of: " + strings.Join(parts, " or ") +} + +// Unwrap exposes the per-schema errors to [errors.Is] and [errors.As]. +func (e OneOfError) Unwrap() []error { + return []error(e) +} + +// MarshalJSON serializes the union failure as {"oneOf": [...]}. Each entry is +// emitted via its own [json.Marshaler] when it implements one — so an [Errors] +// entry becomes a {field: message} object and a nested OneOfError keeps its +// {"oneOf": ...} shape — otherwise it falls back to the entry's message string. +func (e OneOfError) MarshalJSON() ([]byte, error) { + variants := make([]any, len(e)) + for i, err := range e { + switch err := err.(type) { + case nil: + variants[i] = nil + case json.Marshaler: + variants[i] = err + default: + variants[i] = err.Error() + } + } + return json.Marshal(map[string]any{"oneOf": variants}) +} + +// MatchOneOf reports the index of the first schema that fully validates value, +// or (-1, OneOfError) if none do. See [MatchOneOfWithContext] for the details. +// +// It is named MatchOneOf rather than Match because [Match] is already the +// regular-expression rule. +func MatchOneOf(value any, schemas ...Rule) (int, error) { + return MatchOneOfWithContext(context.Background(), value, schemas...) +} + +// MatchOneOfWithContext validates value against each schema in order and returns +// the index of the first one that passes. Evaluation stops at the first match +// (anyOf semantics), so earlier schemas take precedence and the schemas should +// be written to be mutually exclusive. When no schema matches it returns -1 and +// a [OneOfError] holding each schema's failure, in order. +// +// A schema is any [Rule]; the common case is a [MapRule] describing one shape +// of a map. Because a MapRule reports keys it does not list as +// [ErrKeyUnexpected], a map union forbids a field simply by omitting it from +// the variants that disallow it. +// +// If a schema reports a non-validation problem — an [InternalError] such as +// "only a map can be validated" — that error is returned directly rather than +// recorded as a failed variant, so a configuration bug is never mistaken for a +// schema mismatch. +func MatchOneOfWithContext(ctx context.Context, value any, schemas ...Rule) (int, error) { + failures := make(OneOfError, 0, len(schemas)) + for i, schema := range schemas { + err := validateAgainst(ctx, value, schema) + if err == nil { + return i, nil + } + if isInternalError(err) { + return -1, err + } + failures = append(failures, err) + } + return -1, failures +} + +// OneOf returns a validation [Rule] that passes when the value matches at least +// one of the given schemas. It is the rule form of [MatchOneOf] for when you +// only need pass/fail (and want to compose or nest a union inside another rule +// chain). Use [MatchOneOf] when you need to know which schema matched in order +// to act on the input. On failure the rule's error is a [OneOfError]. +// +// By default OneOf uses anyOf semantics: the first matching schema wins and +// evaluation short-circuits. Call [OneOfRule.Strict] to require that exactly +// one schema match. +func OneOf(schemas ...Rule) OneOfRule { + return OneOfRule{schemas: schemas} +} + +// OneOfRule is the rule produced by [OneOf]. +type OneOfRule struct { + schemas []Rule + strict bool +} + +// Strict returns a copy of the rule that requires exactly one schema to match. +// If more than one matches it fails with [ErrAmbiguousMatch], which flags +// schemas that are not mutually exclusive. Unlike the default anyOf behavior, +// strict mode always evaluates every schema. +func (r OneOfRule) Strict() OneOfRule { + r.strict = true + return r +} + +// Validate implements [Rule]. +func (r OneOfRule) Validate(value any) error { + return r.ValidateWithContext(context.Background(), value) +} + +// ValidateWithContext implements [RuleWithContext]. +func (r OneOfRule) ValidateWithContext(ctx context.Context, value any) error { + if !r.strict { + _, err := MatchOneOfWithContext(ctx, value, r.schemas...) + return err + } + + matched := 0 + failures := make(OneOfError, 0, len(r.schemas)) + for _, schema := range r.schemas { + err := validateAgainst(ctx, value, schema) + if err == nil { + matched++ + continue + } + if isInternalError(err) { + return err + } + failures = append(failures, err) + } + switch { + case matched == 1: + return nil + case matched > 1: + return ErrAmbiguousMatch + default: + return failures + } +} + +// MatchOneOfStruct validates structPtr against several alternative field-rule +// schemas and returns the index of the first one that fully validates (anyOf +// semantics — evaluation stops at the first match). When none match it returns +// -1 and a [OneOfError] holding each schema's [Errors], in order. The returned +// index lets the caller act on the matched shape, for example to parse or merge +// it. +// +// MatchOneOfStruct is the struct counterpart to [MatchOneOf]: a struct schema +// is a []*FieldRules (as passed to [ValidateStruct]) rather than a [Rule], +// because field rules are bound to specific struct fields. Use [Never] within a +// schema to forbid the fields a variant disallows. A non-validation error from +// a schema is surfaced directly. +func MatchOneOfStruct[T any]( + ctx context.Context, + structPtr *T, + schemas ...[]*FieldRules, +) (int, error) { + failures := make(OneOfError, 0, len(schemas)) + for i, schema := range schemas { + err := ValidateStructWithContext(ctx, structPtr, schema...) + if err == nil { + return i, nil + } + if isInternalError(err) { + return -1, err + } + failures = append(failures, err) + } + return -1, failures +} + +// validateAgainst runs a single schema against value, honoring the context when +// one is supplied. +func validateAgainst(ctx context.Context, value any, schema Rule) error { + if ctx == nil { + return Validate(value, schema) + } + return ValidateWithContext(ctx, value, schema) +} + +// isInternalError reports whether err is an [InternalError] wrapping a real +// (non-validation) error. +func isInternalError(err error) bool { + ie, ok := err.(InternalError) + return ok && ie.InternalError() != nil +} diff --git a/oneof_test.go b/oneof_test.go new file mode 100644 index 0000000..19c84d6 --- /dev/null +++ b/oneof_test.go @@ -0,0 +1,264 @@ +// Copyright 2016 Qiang Xue. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package validation + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatchOneOf(t *testing.T) { + withEmail := Map( + Key("email", Required), + Key("password", Required), + ) + withUsername := Map( + Key("password", Required), + Key("username", Required), + ) + + t.Run("first schema wins", func(t *testing.T) { + i, err := MatchOneOf(map[string]any{ + "email": "a@b.com", + "password": "hunter2", + }, withEmail, withUsername) + assert.NoError(t, err) + assert.Equal(t, 0, i) + }) + + t.Run("second schema wins", func(t *testing.T) { + i, err := MatchOneOf(map[string]any{ + "username": "bob", + "password": "hunter2", + }, withEmail, withUsername) + assert.NoError(t, err) + assert.Equal(t, 1, i) + }) + + t.Run("no schema matches returns OneOfError per attempt", func(t *testing.T) { + // A map carrying both discriminators matches neither variant: each + // rejects the key it does not list as "key not expected". This is the + // map equivalent of a forbidden field, with no extra rule needed. + i, err := MatchOneOf(map[string]any{ + "email": "a@b.com", + "username": "bob", + "password": "hunter2", + }, withEmail, withUsername) + assert.Equal(t, -1, i) + + oe, ok := err.(OneOfError) + require.True(t, ok, "expected OneOfError, got %T", err) + require.Len(t, oe, 2) + assert.Equal(t, "key not expected", oe[0].(Errors)["username"].Error()) + assert.Equal(t, "key not expected", oe[1].(Errors)["email"].Error()) + }) + + t.Run("internal error from a schema is surfaced directly", func(t *testing.T) { + i, err := MatchOneOf("not a map", withEmail) + assert.Equal(t, -1, i) + _, isInternal := err.(InternalError) + assert.True(t, isInternal, "got %T", err) + _, isOneOf := err.(OneOfError) + assert.False(t, isOneOf) + }) +} + +func TestOneOfRule(t *testing.T) { + // Two overlapping integer sets used as trivial scalar "schemas". + low := In(1, 2) + high := In(2, 3) + + t.Run("anyOf passes when one matches", func(t *testing.T) { + assert.NoError(t, Validate(1, OneOf(low, high))) + }) + + t.Run("anyOf fails with OneOfError when none match", func(t *testing.T) { + err := Validate(5, OneOf(low, high)) + oe, ok := err.(OneOfError) + require.True(t, ok, "got %T", err) + assert.Len(t, oe, 2) + }) + + t.Run("strict passes when exactly one matches", func(t *testing.T) { + assert.NoError(t, Validate(1, OneOf(low, high).Strict())) + }) + + t.Run("strict fails as ambiguous when more than one matches", func(t *testing.T) { + err := Validate(2, OneOf(low, high).Strict()) + assert.Equal(t, ErrAmbiguousMatch, err) + }) + + t.Run("strict fails with OneOfError when none match", func(t *testing.T) { + err := Validate(5, OneOf(low, high).Strict()) + _, ok := err.(OneOfError) + assert.True(t, ok, "got %T", err) + }) +} + +func TestOneOfError_MarshalJSON(t *testing.T) { + t.Run("single alternative still wraps in a oneOf array", func(t *testing.T) { + oe := OneOfError{ + Errors{"name": errors.New("Name must be between 1 and 300 characters")}, + } + raw, err := json.Marshal(oe) + require.NoError(t, err) + + var decoded struct { + OneOf []map[string]string `json:"oneOf"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.Equal(t, []map[string]string{ + {"name": "Name must be between 1 and 300 characters"}, + }, decoded.OneOf) + }) + + t.Run("multiple alternatives", func(t *testing.T) { + oe := OneOfError{ + Errors{"username": errors.New("must not be provided")}, + Errors{"email": errors.New("must not be provided")}, + } + raw, err := json.Marshal(oe) + require.NoError(t, err) + + var decoded struct { + OneOf []map[string]string `json:"oneOf"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.Equal(t, []map[string]string{ + {"username": "must not be provided"}, + {"email": "must not be provided"}, + }, decoded.OneOf) + }) + + t.Run("nested inside a parent Errors recurses", func(t *testing.T) { + // The load-bearing case: a OneOfError sitting as a field value inside a + // parent Errors must serialize structurally, not collapse to a string. + parent := Errors{ + "memo": OneOfError{ + Errors{"name": errors.New("must be a valid value")}, + Errors{"derivedKind": errors.New("cannot be blank")}, + }, + } + raw, err := json.Marshal(parent) + require.NoError(t, err) + + var decoded map[string]struct { + OneOf []map[string]string `json:"oneOf"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Contains(t, decoded, "memo") + assert.Equal(t, []map[string]string{ + {"name": "must be a valid value"}, + {"derivedKind": "cannot be blank"}, + }, decoded["memo"].OneOf) + }) + + t.Run("scalar entries serialize as their message string", func(t *testing.T) { + oe := OneOfError{ + NewError("c1", "must be a valid email address"), + NewError("c2", "must be a valid E164 number"), + } + raw, err := json.Marshal(oe) + require.NoError(t, err) + + var decoded struct { + OneOf []string `json:"oneOf"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.Equal(t, []string{ + "must be a valid email address", + "must be a valid E164 number", + }, decoded.OneOf) + }) +} + +func TestOneOfError_Unwrap(t *testing.T) { + inner := Errors{"name": ErrRequired} + oe := OneOfError{inner, Errors{"other": ErrRequired}} + + assert.Len(t, oe.Unwrap(), 2) + + // errors.As reaches into the variants via Unwrap. + var found Errors + assert.True(t, errors.As(oe, &found)) + assert.Equal(t, inner, found) +} + +type loginPayload struct { + Email string `json:"email"` + Password string `json:"password"` + Username string `json:"username"` +} + +func TestMatchOneOfStruct(t *testing.T) { + schemas := func(l *loginPayload) [][]*FieldRules { + return [][]*FieldRules{ + { // email login: username forbidden + Field(&l.Email, Required), + Field(&l.Password, Required), + Field(&l.Username, Never), + }, + { // username login: email forbidden + Field(&l.Email, Never), + Field(&l.Password, Required), + Field(&l.Username, Required), + }, + } + } + + t.Run("matches the email variant", func(t *testing.T) { + l := loginPayload{Email: "a@b.com", Password: "hunter2"} + s := schemas(&l) + i, err := MatchOneOfStruct(context.Background(), &l, s...) + assert.NoError(t, err) + assert.Equal(t, 0, i) + }) + + t.Run("matches the username variant", func(t *testing.T) { + l := loginPayload{Username: "bob", Password: "hunter2"} + s := schemas(&l) + i, err := MatchOneOfStruct(context.Background(), &l, s...) + assert.NoError(t, err) + assert.Equal(t, 1, i) + }) + + t.Run("no variant matches yields the union error", func(t *testing.T) { + // Both discriminators supplied: each variant fails because Never rejects + // the field it forbids. + l := loginPayload{Email: "a@b.com", Username: "bob", Password: "hunter2"} + s := schemas(&l) + i, err := MatchOneOfStruct(context.Background(), &l, s...) + assert.Equal(t, -1, i) + + oe, ok := err.(OneOfError) + require.True(t, ok, "got %T", err) + require.Len(t, oe, 2) + assert.Equal(t, "must not be provided", oe[0].(Errors)["username"].Error()) + assert.Equal(t, "must not be provided", oe[1].(Errors)["email"].Error()) + }) + + t.Run("union error marshals to the oneOf shape", func(t *testing.T) { + l := loginPayload{Email: "a@b.com", Username: "bob", Password: "hunter2"} + s := schemas(&l) + _, err := MatchOneOfStruct(context.Background(), &l, s...) + + raw, mErr := json.Marshal(err.(OneOfError)) + require.NoError(t, mErr) + + var decoded struct { + OneOf []map[string]string `json:"oneOf"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + assert.Equal(t, []map[string]string{ + {"username": "must not be provided"}, + {"email": "must not be provided"}, + }, decoded.OneOf) + }) +}