From c3068703304fd40de2a07415ca1e0e79f36a116b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 16:59:26 +0000 Subject: [PATCH 1/3] feat: add MAC address validation aligned with Symfony MacAddress - validate.MacAddress with net.ParseMAC (48-bit only) and Symfony type names - it.IsMacAddress with WithType; is.MACAddress; ErrInvalidMAC and translations - Tests and examples for validate, is, it, and shared constraint suite Co-authored-by: Igor Lazarev --- CHANGELOG.md | 1 + errors.go | 1 + is/example_test.go | 10 ++ is/web.go | 6 + it/example_test.go | 16 +++ it/web.go | 79 +++++++++++++ message/messages.go | 1 + message/translations/english/messages.go | 1 + message/translations/russian/messages.go | 1 + test/constraints_test.go | 1 + test/constraints_web_cases_test.go | 102 ++++++++++++++++ validate/example_test.go | 10 ++ validate/macaddress.go | 142 +++++++++++++++++++++++ validate/macaddress_test.go | 112 ++++++++++++++++++ 14 files changed, 483 insertions(+) create mode 100644 validate/macaddress.go create mode 100644 validate/macaddress_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b2918fb..52fab4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- MAC address validation: `it.IsMacAddress()` with `WithType` (Symfony `MacAddress` type names: `validate.MacAddressTypeAll`, `MacAddressTypeBroadcast`, etc.), `validate.MacAddress`, `is.MACAddress`; `validation.ErrInvalidMAC` / `message.InvalidMAC` and English and Russian translations. Only 48-bit (6-octet) addresses accepted via [net.ParseMAC] (colon, hyphen, dot forms); EUI-64 and longer forms are rejected. - ISSN (International Standard Serial Number) validation: `it.IsISSN()`, `validate.ISSN`, `is.ISSN`, with `validation.ErrInvalidISSN` / `message.InvalidISSN` and English and Russian translations (ISO 3297 mod 11 check digit; optional hyphen; behavior aligned with Symfony `Issn`). - BIC / SWIFT validation: `it.IsBIC()` with `CaseInsensitive` and `WithIBAN` (and `WithIBANError` / `WithIBANMessage`), `validate.BIC` with `validate.BICCaseInsensitive` and `validate.BICWithIBAN`, `is.BIC`; `validation.ErrInvalidBIC`, `validation.ErrBICIBANCountryMismatch`, `message.InvalidBIC`, `message.BICNotAssociatedWithIBAN` and English and Russian translations (behavior aligned with Symfony `Bic` / `BicValidator`; country/territory check via `golang.org/x/text/language` regions plus Symfony’s BIC-to-IBAN territory map). - IBAN validation: `it.IsIBAN()`, `validate.IBAN`, `is.IBAN`, with `validation.ErrInvalidIBAN` / `message.InvalidIBAN` and English and Russian translations (behavior aligned with Symfony `Iban`; country patterns from Symfony 7.2 `IbanValidator`). diff --git a/errors.go b/errors.go index f38fb4d..67927a5 100644 --- a/errors.go +++ b/errors.go @@ -25,6 +25,7 @@ var ( ErrInvalidIP = NewError("invalid IP address", message.InvalidIP) ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON) ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN) + ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC) ErrInvalidTime = NewError("invalid time", message.InvalidTime) ErrInvalidULID = NewError("invalid ULID", message.InvalidULID) ErrInvalidUPCA = NewError("invalid UPC-A", message.InvalidUPCA) diff --git a/is/example_test.go b/is/example_test.go index b658235..b8168ba 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -225,6 +225,16 @@ func ExampleCIDR() { // false } +func ExampleMACAddress() { + fmt.Println(is.MACAddress("00:1a:2b:3c:4d:5e")) + fmt.Println(is.MACAddress("bad")) + fmt.Println(is.MACAddress("ff:ff:ff:ff:ff:ff", validate.MacAddressType(validate.MacAddressTypeBroadcast))) + // Output: + // true + // false + // true +} + func ExampleHostname() { fmt.Println(is.Hostname("example.com")) // valid fmt.Println(is.Hostname("example.localhost")) // valid diff --git a/is/web.go b/is/web.go index 325d813..0d21e14 100644 --- a/is/web.go +++ b/is/web.go @@ -59,6 +59,12 @@ func CIDR(value string, options ...func(*validate.CIDROptions)) bool { return validate.CIDR(value, options...) == nil } +// MACAddress checks that a value is a valid 48-bit MAC address with optional Symfony-compatible +// type filtering. See [github.com/muonsoft/validation/validate.MacAddress] for options. +func MACAddress(value string, options ...func(*validate.MacAddressOptions)) bool { + return validate.MacAddress(value, options...) == nil +} + // Hostname checks that a value is a valid hostname. It checks that each label // within a valid hostname may be no more than 63 octets long. Also, it checks that // the total length of the hostname must not exceed 255 characters. diff --git a/it/example_test.go b/it/example_test.go index a937993..f742449 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -993,6 +993,22 @@ func ExampleIsCIDR_invalid() { // violation: "This value is not a valid CIDR notation." } +func ExampleIsMacAddress_valid() { + v := "00:1a:2b:3c:4d:5e" + err := validator.Validate(context.Background(), validation.String(v, it.IsMacAddress())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsMacAddress_invalid() { + v := "not-a-mac" + err := validator.Validate(context.Background(), validation.String(v, it.IsMacAddress())) + fmt.Println(err) + // Output: + // violation: "This value is not a valid MAC address." +} + func ExampleIPConstraint_DenyPrivateIP_restrictedPrivateIPv4() { v := "192.168.1.0" err := validator.Validate(context.Background(), validation.String(v, it.IsIP().DenyPrivateIP())) diff --git a/it/web.go b/it/web.go index 8534656..0222fc4 100644 --- a/it/web.go +++ b/it/web.go @@ -514,3 +514,82 @@ func (c CIDRConstraint) ValidateString(ctx context.Context, validator *validatio func (c CIDRConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error { return c.ValidateString(ctx, validator, &v) } + +// MacAddressConstraint validates a string as a 48-bit MAC address, aligned with +// Symfony\Component\Validator\Constraints\MacAddress. +// +// Accepted input forms are those supported by [net.ParseMAC] for a 6-octet address (colon, hyphen, +// or dot separators). Use [MacAddressConstraint.WithType] to filter by unicast/multicast, local/universal, +// and broadcast, using the same type names as Symfony (see [validate.MacAddressTypeAll] and related constants). +type MacAddressConstraint struct { + isIgnored bool + groups []string + options []func(*validate.MacAddressOptions) + err error + messageTemplate string + messageParameters validation.TemplateParameterList +} + +// IsMacAddress creates a [MacAddressConstraint] with default type [validate.MacAddressTypeAll]. +func IsMacAddress() MacAddressConstraint { + return MacAddressConstraint{ + err: validation.ErrInvalidMAC, + messageTemplate: validation.ErrInvalidMAC.Message(), + } +} + +// WithType sets the Symfony-compatible MAC class filter (default [validate.MacAddressTypeAll]). +func (c MacAddressConstraint) WithType(macType string) MacAddressConstraint { + c.options = append(c.options, validate.MacAddressType(macType)) + return c +} + +// WithError overrides the default error for produced violations. +func (c MacAddressConstraint) WithError(err error) MacAddressConstraint { + c.err = err + return c +} + +// WithMessage sets the violation message template. You can set custom template parameters +// for injecting its values into the final message. Also, you can use default parameters: +// +// {{ value }} - the current (invalid) value. +func (c MacAddressConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) MacAddressConstraint { + c.messageTemplate = template + c.messageParameters = parameters + return c +} + +// When enables conditional validation of this constraint. If the expression evaluates to false, +// then the constraint will be ignored. +func (c MacAddressConstraint) When(condition bool) MacAddressConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups enables conditional validation of the constraint by using the validation groups. +func (c MacAddressConstraint) WhenGroups(groups ...string) MacAddressConstraint { + c.groups = groups + return c +} + +func (c MacAddressConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" { + return nil + } + if validate.MacAddress(*value, c.options...) == nil { + return nil + } + return validator.BuildViolation(ctx, c.err, c.messageTemplate). + WithParameters( + c.messageParameters.Prepend( + validation.TemplateParameter{Key: "{{ value }}", Value: *value}, + )..., + ). + Create() +} + +// Validate implements [validation.Constraint][string] so the constraint can be used with [validation.Each] and [validation.This]. +func (c MacAddressConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error { + return c.ValidateString(ctx, validator, &v) +} diff --git a/message/messages.go b/message/messages.go index 0438f90..4aab02a 100644 --- a/message/messages.go +++ b/message/messages.go @@ -43,6 +43,7 @@ const ( InvalidIP = "This is not a valid IP address." InvalidJSON = "This value should be valid JSON." InvalidLUHN = "Invalid card number." + InvalidMAC = "This value is not a valid MAC address." InvalidTime = "This value is not a valid time." InvalidULID = "This is not a valid ULID." InvalidUPCA = "This value is not a valid UPC-A." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 5eb9e72..93e98fb 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -64,6 +64,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidIP: catalog.String(message.InvalidIP), message.InvalidJSON: catalog.String(message.InvalidJSON), message.InvalidLUHN: catalog.String(message.InvalidLUHN), + message.InvalidMAC: catalog.String(message.InvalidMAC), message.InvalidTime: catalog.String(message.InvalidTime), message.InvalidULID: catalog.String(message.InvalidULID), message.InvalidUPCA: catalog.String(message.InvalidUPCA), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index aa17591..f4e9ac4 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -67,6 +67,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."), message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."), message.InvalidLUHN: catalog.String("Недействительный номер карты."), + message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."), message.InvalidTime: catalog.String("Значение времени недопустимо."), message.InvalidULID: catalog.String("Значение не соответствует формату ULID."), message.InvalidUPCA: catalog.String("Значение не является допустимым UPC-A."), diff --git a/test/constraints_test.go b/test/constraints_test.go index fc157b6..8677217 100644 --- a/test/constraints_test.go +++ b/test/constraints_test.go @@ -62,6 +62,7 @@ var validateTestCases = mergeTestCases( identifierConstraintsTestCases, ipConstraintTestCases, cidrConstraintTestCases, + macAddressConstraintTestCases, isBetweenTimeTestCases, isBlankComparableConstraintTestCases, isBlankConstraintTestCases, diff --git a/test/constraints_web_cases_test.go b/test/constraints_web_cases_test.go index 744ef28..c4fba45 100644 --- a/test/constraints_web_cases_test.go +++ b/test/constraints_web_cases_test.go @@ -8,6 +8,7 @@ import ( "github.com/muonsoft/validation" "github.com/muonsoft/validation/it" "github.com/muonsoft/validation/message" + "github.com/muonsoft/validation/validate" ) var urlConstraintTestCases = []ConstraintValidationTestCase{ @@ -413,6 +414,107 @@ var cidrConstraintTestCases = []ConstraintValidationTestCase{ }, } +var macAddressConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "IsMacAddress passes on nil", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + assert: assertNoError, + }, + { + name: "IsMacAddress passes on empty value", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + stringValue: stringValue(""), + assert: assertNoError, + }, + { + name: "IsMacAddress passes on colon form", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + stringValue: stringValue("00:1a:2b:3c:4d:5e"), + assert: assertNoError, + }, + { + name: "IsMacAddress passes on hyphen form", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + stringValue: stringValue("00-1a-2b-3c-4d-5e"), + assert: assertNoError, + }, + { + name: "IsMacAddress passes on dot form", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + stringValue: stringValue("001a.2b3c.4d5e"), + assert: assertNoError, + }, + { + name: "IsMacAddress violation on invalid", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + stringValue: stringValue("not-mac"), + assert: assertHasOneViolation(validation.ErrInvalidMAC, message.InvalidMAC), + }, + { + name: "IsMacAddress violation on EUI-64 length", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(), + stringValue: stringValue("02:00:5e:10:00:00:00:01"), + assert: assertHasOneViolation(validation.ErrInvalidMAC, message.InvalidMAC), + }, + { + name: "IsMacAddress WithType broadcast only passes broadcast", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress().WithType(validate.MacAddressTypeBroadcast), + stringValue: stringValue("ff:ff:ff:ff:ff:ff"), + assert: assertNoError, + }, + { + name: "IsMacAddress WithType broadcast rejects unicast", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress().WithType(validate.MacAddressTypeBroadcast), + stringValue: stringValue("00:1a:2b:3c:4d:5e"), + assert: assertHasOneViolation(validation.ErrInvalidMAC, message.InvalidMAC), + }, + { + name: "IsMacAddress WithType all_no_broadcast rejects broadcast", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress().WithType(validate.MacAddressTypeAllNoBroadcast), + stringValue: stringValue("ff:ff:ff:ff:ff:ff"), + assert: assertHasOneViolation(validation.ErrInvalidMAC, message.InvalidMAC), + }, + { + name: "IsMacAddress passes when When(false)", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress().When(false), + stringValue: stringValue("bad"), + assert: assertNoError, + }, + { + name: "IsMacAddress passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress().WhenGroups(testGroup), + stringValue: stringValue("bad"), + assert: assertNoError, + }, + { + name: "IsMacAddress violation with custom error and message", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsMacAddress(). + WithError(ErrCustom). + WithMessage( + `Bad MAC "{{ value }}" at {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "field"}, + ), + stringValue: stringValue("xx"), + assert: assertHasOneViolation( + ErrCustom, + `Bad MAC "xx" at field.`, + ), + }, +} + var hostnameConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsHostname passes on valid hostname", diff --git a/validate/example_test.go b/validate/example_test.go index 23631fe..5a80658 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -146,6 +146,16 @@ func ExampleCIDR() { // invalid CIDR } +func ExampleMacAddress() { + fmt.Println(validate.MacAddress("00:1a:2b:3c:4d:5e")) + fmt.Println(validate.MacAddress("not-mac")) + fmt.Println(validate.MacAddress("ff:ff:ff:ff:ff:ff", validate.MacAddressType(validate.MacAddressTypeAllNoBroadcast))) + // Output: + // + // invalid MAC address + // invalid MAC address +} + func ExampleULID() { fmt.Println(validate.ULID("01ARZ3NDEKTSV4RRFFQ69G5FAV")) fmt.Println(validate.ULID("01ARZ3NDEKTSV4RRFFQ69G5FA")) diff --git a/validate/macaddress.go b/validate/macaddress.go new file mode 100644 index 0000000..d15c7df --- /dev/null +++ b/validate/macaddress.go @@ -0,0 +1,142 @@ +package validate + +import ( + "bytes" + "errors" + "net" +) + +// ErrInvalidMAC is returned when the value is not a valid 48-bit MAC address for the +// configured checks, or when an unknown Symfony type name was set via [MacAddressType]. +var ErrInvalidMAC = errors.New("invalid MAC address") + +// MAC address type strings match Symfony\Component\Validator\Constraints\MacAddress. +const ( + MacAddressTypeAll = "all" + MacAddressTypeAllNoBroadcast = "all_no_broadcast" + MacAddressTypeLocalAll = "local_all" + MacAddressTypeLocalNoBroadcast = "local_no_broadcast" + MacAddressTypeLocalUnicast = "local_unicast" + MacAddressTypeLocalMulticast = "local_multicast" + MacAddressTypeLocalMulticastNoBroadcast = "local_multicast_no_broadcast" + MacAddressTypeUniversalAll = "universal_all" + MacAddressTypeUniversalUnicast = "universal_unicast" + MacAddressTypeUniversalMulticast = "universal_multicast" + MacAddressTypeUnicastAll = "unicast_all" + MacAddressTypeMulticastAll = "multicast_all" + MacAddressTypeMulticastNoBroadcast = "multicast_no_broadcast" + MacAddressTypeBroadcast = "broadcast" +) + +// MacAddressOptions configures [MacAddress] validation (Symfony MacAddress "type"). +type MacAddressOptions struct { + Type string +} + +func newMacAddressOptions() *MacAddressOptions { + return &MacAddressOptions{Type: MacAddressTypeAll} +} + +// MacAddressType sets the Symfony-compatible MAC class filter (default [MacAddressTypeAll]). +func MacAddressType(t string) func(*MacAddressOptions) { + return func(o *MacAddressOptions) { + o.Type = t + } +} + +// MacAddress validates that value is a 48-bit IEEE 802 MAC address accepted by [net.ParseMAC] +// (colon, hyphen, or dot-separated forms), then applies the Symfony [MacAddress] "type" rules +// on the first octet’s I/G and U/L bits and on the broadcast address ff:ff:ff:ff:ff:ff. +// +// Returns [ErrInvalidMAC] when the string is not a 48-bit MAC, when it is not parseable, or +// when an unknown type name was set via [MacAddressType]. +func MacAddress(value string, options ...func(*MacAddressOptions)) error { + opts := newMacAddressOptions() + for _, opt := range options { + opt(opts) + } + if _, ok := macAddressTypePredicates[opts.Type]; !ok { + return ErrInvalidMAC + } + hw, err := net.ParseMAC(value) + if err != nil || len(hw) != 6 { + return ErrInvalidMAC + } + if !macAddressMatchesType(hw, opts.Type) { + return ErrInvalidMAC + } + return nil +} + +var macBroadcast6 = []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + +// macAddressClass holds I/G (unicast vs multicast), U/L (universal vs local), and broadcast +// flags derived from a 48-bit MAC, matching Symfony MacAddressValidator. +type macAddressClass struct { + unicast bool + local bool + broadcast bool +} + +func macAddressClassOf(hw net.HardwareAddr) macAddressClass { + first := hw[0] + return macAddressClass{ + unicast: first&1 == 0, + local: first&2 != 0, + broadcast: bytes.Equal(hw, macBroadcast6), + } +} + +// macAddressTypePredicates maps Symfony MacAddress "type" to a predicate on [macAddressClass]. +var macAddressTypePredicates = map[string]func(macAddressClass) bool{ + MacAddressTypeAll: func(c macAddressClass) bool { + return true + }, + MacAddressTypeAllNoBroadcast: func(c macAddressClass) bool { + return !c.broadcast + }, + MacAddressTypeLocalAll: func(c macAddressClass) bool { + return c.local + }, + MacAddressTypeLocalNoBroadcast: func(c macAddressClass) bool { + return c.local && !c.broadcast + }, + MacAddressTypeLocalUnicast: func(c macAddressClass) bool { + return c.local && c.unicast + }, + MacAddressTypeLocalMulticast: func(c macAddressClass) bool { + return c.local && !c.unicast + }, + MacAddressTypeLocalMulticastNoBroadcast: func(c macAddressClass) bool { + return c.local && !c.unicast && !c.broadcast + }, + MacAddressTypeUniversalAll: func(c macAddressClass) bool { + return !c.local + }, + MacAddressTypeUniversalUnicast: func(c macAddressClass) bool { + return !c.local && c.unicast + }, + MacAddressTypeUniversalMulticast: func(c macAddressClass) bool { + return !c.local && !c.unicast + }, + MacAddressTypeUnicastAll: func(c macAddressClass) bool { + return c.unicast + }, + MacAddressTypeMulticastAll: func(c macAddressClass) bool { + return !c.unicast + }, + MacAddressTypeMulticastNoBroadcast: func(c macAddressClass) bool { + return !c.unicast && !c.broadcast + }, + MacAddressTypeBroadcast: func(c macAddressClass) bool { + return c.broadcast + }, +} + +func macAddressMatchesType(hw net.HardwareAddr, typ string) bool { + pred, ok := macAddressTypePredicates[typ] + if !ok { + return false + } + return pred(macAddressClassOf(hw)) +} diff --git a/validate/macaddress_test.go b/validate/macaddress_test.go new file mode 100644 index 0000000..d368944 --- /dev/null +++ b/validate/macaddress_test.go @@ -0,0 +1,112 @@ +package validate + +import ( + "errors" + "testing" +) + +func TestMacAddress(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + opts []func(*MacAddressOptions) + wantErr bool + }{ + {name: "colon", value: "00:1a:2b:3c:4d:5e"}, + {name: "hyphen", value: "00-1a-2b-3c-4d-5e"}, + {name: "dot", value: "001a.2b3c.4d5e"}, + {name: "uppercase", value: "00:1A:2B:3C:4D:5E"}, + {name: "invalid char", value: "00:1g:2b:3c:4d:5e", wantErr: true}, + {name: "too short", value: "00:11:22:33:44", wantErr: true}, + {name: "eui64 rejected", value: "02:00:5e:10:00:00:00:01", wantErr: true}, + {name: "empty", value: "", wantErr: true}, + {name: "garbage", value: "not-a-mac", wantErr: true}, + { + name: "broadcast passes all", + value: "ff:ff:ff:ff:ff:ff", + opts: nil, + wantErr: false, + }, + { + name: "broadcast fails all_no_broadcast", + value: "ff:ff:ff:ff:ff:ff", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeAllNoBroadcast)}, + wantErr: true, + }, + { + name: "broadcast type", + value: "ff:ff:ff:ff:ff:ff", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeBroadcast)}, + }, + { + name: "unicast fails broadcast type", + value: "00:1a:2b:3c:4d:5e", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeBroadcast)}, + wantErr: true, + }, + { + name: "local unicast", + value: "02:00:00:00:00:01", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeLocalUnicast)}, + }, + { + name: "universal unicast rejects local", + value: "02:00:00:00:00:01", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeUniversalUnicast)}, + wantErr: true, + }, + { + name: "universal unicast", + value: "00:1a:2b:3c:4d:5e", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeUniversalUnicast)}, + }, + { + name: "local all rejects universal", + value: "00:1a:2b:3c:4d:5e", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeLocalAll)}, + wantErr: true, + }, + { + name: "multicast all", + value: "01:00:5e:00:00:01", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeMulticastAll)}, + }, + { + name: "unicast all rejects multicast", + value: "01:00:5e:00:00:01", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeUnicastAll)}, + wantErr: true, + }, + { + name: "multicast no broadcast rejects broadcast", + value: "ff:ff:ff:ff:ff:ff", + opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeMulticastNoBroadcast)}, + wantErr: true, + }, + { + name: "unknown type", + value: "00:1a:2b:3c:4d:5e", + opts: []func(*MacAddressOptions){MacAddressType("bogus")}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := MacAddress(tt.value, tt.opts...) + if tt.wantErr { + if err == nil { + t.Fatalf("MacAddress(%q): want error", tt.value) + } + if !errors.Is(err, ErrInvalidMAC) { + t.Fatalf("MacAddress(%q): got %v, want %v", tt.value, err, ErrInvalidMAC) + } + } else if err != nil { + t.Fatalf("MacAddress(%q): %v", tt.value, err) + } + }) + } +} From d242864b7a2e55bc8b16fc711db4489436628f24 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Sun, 12 Apr 2026 22:13:37 +0300 Subject: [PATCH 2/3] refactor(validate): MacAddressType, WithMacAddressType; black-box tests; AGENTS Made-with: Cursor --- .../skills/validation-add-constraint/SKILL.md | 4 +- AGENTS.md | 79 ++++++++++--------- is/example_test.go | 2 +- it/web.go | 12 ++- validate/example_test.go | 2 +- validate/macaddress.go | 47 +++++------ validate/macaddress_test.go | 34 ++++---- 7 files changed, 99 insertions(+), 81 deletions(-) diff --git a/.cursor/skills/validation-add-constraint/SKILL.md b/.cursor/skills/validation-add-constraint/SKILL.md index 0aee9b1..425555d 100644 --- a/.cursor/skills/validation-add-constraint/SKILL.md +++ b/.cursor/skills/validation-add-constraint/SKILL.md @@ -185,7 +185,7 @@ Add the slice to **`validateTestCases`** in **`test/constraints_test.go`** via ` ### Unit tests in `validate` for structured validation -If the constraint is implemented or configured through **`validate`** (parsers, options, several error kinds, version-specific rules), add **focused unit tests** in **`validate/*_test.go`** in addition to the shared `test/` constraint cases. +If the constraint is implemented or configured through **`validate`** (parsers, options, several error kinds, version-specific rules), add **focused unit tests** in **`validate/*_test.go`** in addition to the shared `test/` constraint cases. Use **`package validate_test`** (black-box) so tests only call the exported API unless unexported helpers must be covered. For validations that depend on **structure** (splitting strings, numeric ranges, IPv4 vs IPv6 branches, composition of options), aim for **strong coverage**: boundary values (min/max inclusive), each error path, and helper functions used for messages (e.g. bounds for templates). Table-driven subtests work well. Shallow happy-path-only tests are easy to miss regressions for this kind of logic. @@ -223,7 +223,7 @@ Add when the constraint is useful for **standalone validation** (e.g. string cod - **Signature**: `func MyFormat(value string) error` (or with options). - **Return**: `nil` if valid; otherwise a sentinel error from `validate` package (e.g. `ErrTooShort`, or custom). - **Godoc**: Describe when it returns which error. -- **Tests**: `validate/*_test.go` table or cases; for non-trivial parsing or options, prefer **broad unit coverage** (see [Tests / Unit tests in validate](#4-tests-mandatory)). +- **Tests**: `validate/*_test.go` with `package validate_test` and table-driven cases; for non-trivial parsing or options, prefer **broad unit coverage** (see [Tests / Unit tests in validate](#4-tests-mandatory)). - **Examples**: `validate/example_test.go` with `ExampleMyFormat` and `// Output:`. If the `it` constraint needs options, define option types and funcs in `validate` (e.g. `validate.MyOptions`, `validate.AllowXxx()`), and use them from `it` and `is`. diff --git a/AGENTS.md b/AGENTS.md index 24f1861..5bae870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ This document provides guidance for AI coding agents working with this Go valida ## Project Overview This is a comprehensive Go validation library that provides: + - Declarative validation using struct tags - Chainable validation constraints via `it` package - Conditional checks via `is` package @@ -16,14 +17,14 @@ This is a comprehensive Go validation library that provides: ### Core Components -- **`validation.go`** - Main validation entry points and `Validatable` interface -- **`validator.go`** - Core validation logic and execution -- **`constraints.go`** - Constraint interface and execution -- **`it/`** - Constraint builders for assertions (e.g., `it.IsEmail()`, `it.MinLength()`) -- **`is/`** - Boolean check functions (e.g., `is.Email()`, `is.URL()`) -- **`validate/`** - Standalone validation functions -- **`violations.go`** - Validation error handling -- **`message/`** - Message templating and translation system +- `**validation.go**` - Main validation entry points and `Validatable` interface +- `**validator.go**` - Core validation logic and execution +- `**constraints.go**` - Constraint interface and execution +- `**it/**` - Constraint builders for assertions (e.g., `it.IsEmail()`, `it.MinLength()`) +- `**is/**` - Boolean check functions (e.g., `is.Email()`, `is.URL()`) +- `**validate/**` - Standalone validation functions +- `**violations.go**` - Validation error handling +- `**message/**` - Message templating and translation system ### Validation Flow @@ -39,19 +40,16 @@ This is a comprehensive Go validation library that provides: When adding new validation constraints: 1. **Add boolean check to `is/` package** (if needed) - - Pure functions returning `bool` - - No error handling, just true/false - + - Pure functions returning `bool` + - No error handling, just true/false 2. **Add constraint builder to `it/` package** - - Returns a `validation.Constraint` - - Uses corresponding `is/` function - - Defines violation message template - + - Returns a `validation.Constraint` + - Uses corresponding `is/` function + - Defines violation message template 3. **Add tests in `test/constraints_*_cases_test.go`** - - Use table-driven tests - - Test both valid and invalid cases - - Include edge cases - + - Use table-driven tests + - Test both valid and invalid cases + - Include edge cases 4. **Add examples** in relevant `example_*_test.go` files ### Writing Tests @@ -77,6 +75,13 @@ func TestConstraintName(t *testing.T) { } ``` +**Black-box tests next to package code** (`is/`, `it/`, `validate/`, root `validation` package, etc.): + +- Prefer **black-box testing**: put tests in `*_test.go` files that declare `**package foo_test`** (the package name must end with the `_test` suffix), and import `**github.com/muonsoft/validation/foo**` to exercise only the **exported** API. +- Use `**package foo`** in the same directory only when the test must call **unexported** identifiers (unusual); keep those cases minimal and justified. + +Integration-style constraint suites stay in `**test/`** as today (`test/constraints_*_cases_test.go`). + ### Message Templates When defining new constraints, use message templates: @@ -89,31 +94,30 @@ validation.NewError( ``` Add translations in: + - `message/translations/english/messages.go` - `message/translations/russian/messages.go` ## Code Style Guidelines 1. **Naming Conventions** - - Use clear, descriptive names - - Constraint builders: `it.IsXxx()`, `it.HasXxx()`, `it.Xxx()` - - Check functions: `is.Xxx()` - + - Use clear, descriptive names + - Constraint builders: `it.IsXxx()`, `it.HasXxx()`, `it.Xxx()` + - Check functions: `is.Xxx()` 2. **Documentation** - - All exported functions must have godoc comments - - Include examples in `example_*_test.go` files - - Use `// Output:` comments for testable examples - + - All exported functions must have godoc comments + - Include examples in `example_*_test.go` files + - Use `// Output:` comments for testable examples 3. **Error Handling** - - Use `violations.go` types for validation errors - - Preserve constraint paths for nested validations - - Provide clear, actionable error messages - + - Use `violations.go` types for validation errors + - Preserve constraint paths for nested validations + - Provide clear, actionable error messages 4. **Testing** - - Aim for high test coverage - - Use table-driven tests - - Test edge cases and error conditions - - Include benchmarks for performance-critical code + - Aim for high test coverage + - Use table-driven tests + - Test edge cases and error conditions + - Include benchmarks for performance-critical code + - For unit tests in `is/`, `it/`, `validate/`, etc., use `**package foo_test`** black-box tests (see [Writing Tests](#writing-tests)) ## File Organization @@ -122,7 +126,7 @@ When adding new functionality: - **Constraints** → `it/` package - **Check functions** → `is/` package - **Standalone validators** → `validate/` package -- **Tests** → `test/` directory +- **Tests** → shared constraint suites in `test/`; package unit tests in `*_test.go` with `package foo_test` where applicable - **Examples** → `example_*_test.go` in root - **Messages** → `message/translations/` @@ -186,7 +190,7 @@ func (c NumericConstraint) ValidateString(ctx context.Context, validator *valida ## Changelog -The project uses **[Keep a Changelog](https://keepachangelog.com/)** in **`CHANGELOG.md`**. +The project uses **[Keep a Changelog](https://keepachangelog.com/)** in `**CHANGELOG.md`**. ### Rules for agents and contributors @@ -221,3 +225,4 @@ This is a pure Go library with no external services or infrastructure dependenci - The CI workflow (`.github/workflows/tests.yml`) pins `golangci-lint` at **v2.11.4** and Go at **^1.24**. Match these versions locally. - The `.golangci.yml` uses config **version: "2"** (golangci-lint v2 format). Do not use golangci-lint v1. - No Makefile, Docker, or docker-compose is used. No services need to be started. + diff --git a/is/example_test.go b/is/example_test.go index b8168ba..b42f775 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -228,7 +228,7 @@ func ExampleCIDR() { func ExampleMACAddress() { fmt.Println(is.MACAddress("00:1a:2b:3c:4d:5e")) fmt.Println(is.MACAddress("bad")) - fmt.Println(is.MACAddress("ff:ff:ff:ff:ff:ff", validate.MacAddressType(validate.MacAddressTypeBroadcast))) + fmt.Println(is.MACAddress("ff:ff:ff:ff:ff:ff", validate.WithMacAddressType(validate.MacAddressTypeBroadcast))) // Output: // true // false diff --git a/it/web.go b/it/web.go index 0222fc4..cd2f4d4 100644 --- a/it/web.go +++ b/it/web.go @@ -539,8 +539,16 @@ func IsMacAddress() MacAddressConstraint { } // WithType sets the Symfony-compatible MAC class filter (default [validate.MacAddressTypeAll]). -func (c MacAddressConstraint) WithType(macType string) MacAddressConstraint { - c.options = append(c.options, validate.MacAddressType(macType)) +// +// Allowed values are [validate.MacAddressTypeAll], [validate.MacAddressTypeAllNoBroadcast], +// [validate.MacAddressTypeLocalAll], [validate.MacAddressTypeLocalNoBroadcast], +// [validate.MacAddressTypeLocalUnicast], [validate.MacAddressTypeLocalMulticast], +// [validate.MacAddressTypeLocalMulticastNoBroadcast], [validate.MacAddressTypeUniversalAll], +// [validate.MacAddressTypeUniversalUnicast], [validate.MacAddressTypeUniversalMulticast], +// [validate.MacAddressTypeUnicastAll], [validate.MacAddressTypeMulticastAll], +// [validate.MacAddressTypeMulticastNoBroadcast], and [validate.MacAddressTypeBroadcast]. +func (c MacAddressConstraint) WithType(macType validate.MacAddressType) MacAddressConstraint { + c.options = append(c.options, validate.WithMacAddressType(macType)) return c } diff --git a/validate/example_test.go b/validate/example_test.go index 5a80658..47d3222 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -149,7 +149,7 @@ func ExampleCIDR() { func ExampleMacAddress() { fmt.Println(validate.MacAddress("00:1a:2b:3c:4d:5e")) fmt.Println(validate.MacAddress("not-mac")) - fmt.Println(validate.MacAddress("ff:ff:ff:ff:ff:ff", validate.MacAddressType(validate.MacAddressTypeAllNoBroadcast))) + fmt.Println(validate.MacAddress("ff:ff:ff:ff:ff:ff", validate.WithMacAddressType(validate.MacAddressTypeAllNoBroadcast))) // Output: // // invalid MAC address diff --git a/validate/macaddress.go b/validate/macaddress.go index d15c7df..cd23af3 100644 --- a/validate/macaddress.go +++ b/validate/macaddress.go @@ -7,38 +7,41 @@ import ( ) // ErrInvalidMAC is returned when the value is not a valid 48-bit MAC address for the -// configured checks, or when an unknown Symfony type name was set via [MacAddressType]. +// configured checks, or when an unknown Symfony type name was set via [WithMacAddressType]. var ErrInvalidMAC = errors.New("invalid MAC address") -// MAC address type strings match Symfony\Component\Validator\Constraints\MacAddress. +// MacAddressType represents Symfony\Component\Validator\Constraints\MacAddress "type". +type MacAddressType string + +// MAC address type values match Symfony\Component\Validator\Constraints\MacAddress. const ( - MacAddressTypeAll = "all" - MacAddressTypeAllNoBroadcast = "all_no_broadcast" - MacAddressTypeLocalAll = "local_all" - MacAddressTypeLocalNoBroadcast = "local_no_broadcast" - MacAddressTypeLocalUnicast = "local_unicast" - MacAddressTypeLocalMulticast = "local_multicast" - MacAddressTypeLocalMulticastNoBroadcast = "local_multicast_no_broadcast" - MacAddressTypeUniversalAll = "universal_all" - MacAddressTypeUniversalUnicast = "universal_unicast" - MacAddressTypeUniversalMulticast = "universal_multicast" - MacAddressTypeUnicastAll = "unicast_all" - MacAddressTypeMulticastAll = "multicast_all" - MacAddressTypeMulticastNoBroadcast = "multicast_no_broadcast" - MacAddressTypeBroadcast = "broadcast" + MacAddressTypeAll MacAddressType = "all" + MacAddressTypeAllNoBroadcast MacAddressType = "all_no_broadcast" + MacAddressTypeLocalAll MacAddressType = "local_all" + MacAddressTypeLocalNoBroadcast MacAddressType = "local_no_broadcast" + MacAddressTypeLocalUnicast MacAddressType = "local_unicast" + MacAddressTypeLocalMulticast MacAddressType = "local_multicast" + MacAddressTypeLocalMulticastNoBroadcast MacAddressType = "local_multicast_no_broadcast" + MacAddressTypeUniversalAll MacAddressType = "universal_all" + MacAddressTypeUniversalUnicast MacAddressType = "universal_unicast" + MacAddressTypeUniversalMulticast MacAddressType = "universal_multicast" + MacAddressTypeUnicastAll MacAddressType = "unicast_all" + MacAddressTypeMulticastAll MacAddressType = "multicast_all" + MacAddressTypeMulticastNoBroadcast MacAddressType = "multicast_no_broadcast" + MacAddressTypeBroadcast MacAddressType = "broadcast" ) // MacAddressOptions configures [MacAddress] validation (Symfony MacAddress "type"). type MacAddressOptions struct { - Type string + Type MacAddressType } func newMacAddressOptions() *MacAddressOptions { return &MacAddressOptions{Type: MacAddressTypeAll} } -// MacAddressType sets the Symfony-compatible MAC class filter (default [MacAddressTypeAll]). -func MacAddressType(t string) func(*MacAddressOptions) { +// WithMacAddressType sets the Symfony-compatible MAC class filter (default [MacAddressTypeAll]). +func WithMacAddressType(t MacAddressType) func(*MacAddressOptions) { return func(o *MacAddressOptions) { o.Type = t } @@ -49,7 +52,7 @@ func MacAddressType(t string) func(*MacAddressOptions) { // on the first octet’s I/G and U/L bits and on the broadcast address ff:ff:ff:ff:ff:ff. // // Returns [ErrInvalidMAC] when the string is not a 48-bit MAC, when it is not parseable, or -// when an unknown type name was set via [MacAddressType]. +// when an unknown type name was set via [WithMacAddressType]. func MacAddress(value string, options ...func(*MacAddressOptions)) error { opts := newMacAddressOptions() for _, opt := range options { @@ -88,7 +91,7 @@ func macAddressClassOf(hw net.HardwareAddr) macAddressClass { } // macAddressTypePredicates maps Symfony MacAddress "type" to a predicate on [macAddressClass]. -var macAddressTypePredicates = map[string]func(macAddressClass) bool{ +var macAddressTypePredicates = map[MacAddressType]func(macAddressClass) bool{ MacAddressTypeAll: func(c macAddressClass) bool { return true }, @@ -133,7 +136,7 @@ var macAddressTypePredicates = map[string]func(macAddressClass) bool{ }, } -func macAddressMatchesType(hw net.HardwareAddr, typ string) bool { +func macAddressMatchesType(hw net.HardwareAddr, typ MacAddressType) bool { pred, ok := macAddressTypePredicates[typ] if !ok { return false diff --git a/validate/macaddress_test.go b/validate/macaddress_test.go index d368944..2a441d8 100644 --- a/validate/macaddress_test.go +++ b/validate/macaddress_test.go @@ -1,8 +1,10 @@ -package validate +package validate_test import ( "errors" "testing" + + "github.com/muonsoft/validation/validate" ) func TestMacAddress(t *testing.T) { @@ -11,7 +13,7 @@ func TestMacAddress(t *testing.T) { tests := []struct { name string value string - opts []func(*MacAddressOptions) + opts []func(*validate.MacAddressOptions) wantErr bool }{ {name: "colon", value: "00:1a:2b:3c:4d:5e"}, @@ -32,63 +34,63 @@ func TestMacAddress(t *testing.T) { { name: "broadcast fails all_no_broadcast", value: "ff:ff:ff:ff:ff:ff", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeAllNoBroadcast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeAllNoBroadcast)}, wantErr: true, }, { name: "broadcast type", value: "ff:ff:ff:ff:ff:ff", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeBroadcast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeBroadcast)}, }, { name: "unicast fails broadcast type", value: "00:1a:2b:3c:4d:5e", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeBroadcast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeBroadcast)}, wantErr: true, }, { name: "local unicast", value: "02:00:00:00:00:01", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeLocalUnicast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeLocalUnicast)}, }, { name: "universal unicast rejects local", value: "02:00:00:00:00:01", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeUniversalUnicast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeUniversalUnicast)}, wantErr: true, }, { name: "universal unicast", value: "00:1a:2b:3c:4d:5e", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeUniversalUnicast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeUniversalUnicast)}, }, { name: "local all rejects universal", value: "00:1a:2b:3c:4d:5e", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeLocalAll)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeLocalAll)}, wantErr: true, }, { name: "multicast all", value: "01:00:5e:00:00:01", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeMulticastAll)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeMulticastAll)}, }, { name: "unicast all rejects multicast", value: "01:00:5e:00:00:01", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeUnicastAll)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeUnicastAll)}, wantErr: true, }, { name: "multicast no broadcast rejects broadcast", value: "ff:ff:ff:ff:ff:ff", - opts: []func(*MacAddressOptions){MacAddressType(MacAddressTypeMulticastNoBroadcast)}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType(validate.MacAddressTypeMulticastNoBroadcast)}, wantErr: true, }, { name: "unknown type", value: "00:1a:2b:3c:4d:5e", - opts: []func(*MacAddressOptions){MacAddressType("bogus")}, + opts: []func(*validate.MacAddressOptions){validate.WithMacAddressType("bogus")}, wantErr: true, }, } @@ -96,13 +98,13 @@ func TestMacAddress(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := MacAddress(tt.value, tt.opts...) + err := validate.MacAddress(tt.value, tt.opts...) if tt.wantErr { if err == nil { t.Fatalf("MacAddress(%q): want error", tt.value) } - if !errors.Is(err, ErrInvalidMAC) { - t.Fatalf("MacAddress(%q): got %v, want %v", tt.value, err, ErrInvalidMAC) + if !errors.Is(err, validate.ErrInvalidMAC) { + t.Fatalf("MacAddress(%q): got %v, want %v", tt.value, err, validate.ErrInvalidMAC) } } else if err != nil { t.Fatalf("MacAddress(%q): %v", tt.value, err) From da01cb3e790c465de3516c9a7131e2c82d740ec8 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Sun, 12 Apr 2026 22:15:57 +0300 Subject: [PATCH 3/3] chore(changelog): add links to versions in changelog --- CHANGELOG.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe3b0f..4b37972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased](https://github.com/muonsoft/validation/compare/v0.19.0...HEAD) ### Added @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ISIN (International Securities Identification Number) validation: `it.IsISIN()`, `validate.ISIN`, `is.ISIN`, with `validation.ErrInvalidISIN` / `message.InvalidISIN` and English and Russian translations (behavior aligned with Symfony `Isin`). - **HasUniqueValuesBy**: `SkipEmptyKeys()` on `it.UniqueByConstraint` skips elements whose key equals the zero value for `K`, so they are not counted toward uniqueness (e.g. optional IDs). -## [0.19.0] - 2026-02-09 +## [0.19.0](https://github.com/muonsoft/validation/releases/tag/v0.19.0) - 2026-02-09 ### Added @@ -41,7 +41,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Correct handling of single violations returned from validatable objects in `validateIt`. - -[Unreleased]: https://github.com/muonsoft/validation/compare/v0.19.0...HEAD -[0.19.0]: https://github.com/muonsoft/validation/releases/tag/v0.19.0 +- Correct handling of single violations returned from validatable objects in `validateIt`. \ No newline at end of file