diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b37972..1108eb2 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 +- ISO 4217 currency code validation: `it.IsCurrency()`, `validate.Currency`, `is.Currency`, with `validation.ErrInvalidCurrency` / `message.InvalidCurrency` and English and Russian translations (behavior aligned with Symfony `Currency`; recognized codes from `golang.org/x/text/currency.ParseISO`). - ISBN validation: `it.IsISBN()` with `Only10` / `Only13`, `validate.ISBN` with `validate.ISBNOnly10` / `validate.ISBNOnly13`, `is.ISBN`; `validation.ErrInvalidISBN`, `ErrInvalidISBN10`, `ErrInvalidISBN13` / `message.InvalidISBN`, `InvalidISBN10`, `InvalidISBN13` and English and Russian translations (behavior aligned with Symfony `Isbn`). - MAC address validation: `it.IsMacAddress()` with `WithType` (Symfony `MacAddress` type names: `validate.MacAddressTypeAll`, `MacAddressTypeBroadcast`, etc.), `validate.MacAddress` with `validate.WithMacAddressType`, `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`). diff --git a/errors.go b/errors.go index 9ae36bd..3639618 100644 --- a/errors.go +++ b/errors.go @@ -23,6 +23,7 @@ var ( ErrInvalidISBN = NewError("invalid ISBN", message.InvalidISBN) ErrInvalidISBN10 = NewError("invalid ISBN-10", message.InvalidISBN10) ErrInvalidISBN13 = NewError("invalid ISBN-13", message.InvalidISBN13) + ErrInvalidCurrency = NewError("invalid currency", message.InvalidCurrency) ErrInvalidCIDR = NewError("invalid CIDR", message.InvalidCIDR) ErrCIDRNetmaskOutOfRange = NewError("CIDR netmask out of range", message.CIDRNetmaskOutOfRange) ErrInvalidIP = NewError("invalid IP address", message.InvalidIP) diff --git a/is/example_test.go b/is/example_test.go index 99e42d9..182131a 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -283,6 +283,16 @@ func ExampleIBAN() { // false } +func ExampleCurrency() { + fmt.Println(is.Currency("EUR")) + fmt.Println(is.Currency("ZZZ")) + fmt.Println(is.Currency("EU")) + // Output: + // true + // false + // false +} + func ExampleBIC() { fmt.Println(is.BIC("DEUTDEFF")) fmt.Println(is.BIC("DEUTDEF")) diff --git a/is/identifiers.go b/is/identifiers.go index 8c0d2e2..459e33b 100644 --- a/is/identifiers.go +++ b/is/identifiers.go @@ -56,6 +56,14 @@ func LUHN(value string) bool { return validate.LUHN(value) == nil } +// Currency validates whether the value is a recognized ISO 4217 alphabetic currency code. +// See [github.com/muonsoft/validation/validate.Currency] for rules and possible errors. +// +// See https://www.iso.org/iso-4217-currency-codes.html. +func Currency(value string) bool { + return validate.Currency(value) == nil +} + // UUID validates whether a string value is a valid UUID (also known as GUID). // // By default, it uses strict mode and checks the UUID as specified in RFC 4122. diff --git a/it/example_test.go b/it/example_test.go index 452c8df..a9561cc 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -55,6 +55,20 @@ func ExampleIsIBAN_invalid() { // violation: "This is not a valid International Bank Account Number (IBAN)." } +func ExampleIsCurrency_valid() { + err := validator.Validate(context.Background(), validation.String("CHF", it.IsCurrency())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsCurrency_invalid() { + err := validator.Validate(context.Background(), validation.String("ZZZ", it.IsCurrency())) + fmt.Println(err) + // Output: + // violation: "This value is not a valid currency." +} + func ExampleIsBIC_valid() { err := validator.Validate(context.Background(), validation.String("DEUTDEFF", it.IsBIC())) fmt.Println(err) diff --git a/it/identifiers.go b/it/identifiers.go index c61e53a..71b9ee4 100644 --- a/it/identifiers.go +++ b/it/identifiers.go @@ -54,6 +54,17 @@ func IsLUHN() validation.StringFuncConstraint { WithMessage(validation.ErrInvalidLUHN.Message()) } +// IsCurrency validates whether the value is a recognized ISO 4217 alphabetic currency code, +// as in Symfony\Component\Validator\Constraints\Currency. +// Recognition follows [golang.org/x/text/currency.ParseISO] (CLDR currency data). +// +// See https://www.iso.org/iso-4217-currency-codes.html. +func IsCurrency() validation.StringFuncConstraint { + return validation.OfStringBy(is.Currency). + WithError(validation.ErrInvalidCurrency). + WithMessage(validation.ErrInvalidCurrency.Message()) +} + // UUIDConstraint validates whether a string value is a valid UUID (also known as GUID). // // By default, it uses strict mode and checks the UUID as specified in RFC 4122. diff --git a/message/messages.go b/message/messages.go index a6c26bf..9e23904 100644 --- a/message/messages.go +++ b/message/messages.go @@ -41,6 +41,7 @@ const ( InvalidISBN = "This value is neither a valid ISBN-10 nor a valid ISBN-13." InvalidISBN10 = "This value is not a valid ISBN-10." InvalidISBN13 = "This value is not a valid ISBN-13." + InvalidCurrency = "This value is not a valid currency." InvalidCIDR = "This value is not a valid CIDR notation." CIDRNetmaskOutOfRange = "The value of the netmask should be between {{ min }} and {{ max }}." InvalidIP = "This is not a valid IP address." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 97619f3..262fde0 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -62,6 +62,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidISBN: catalog.String(message.InvalidISBN), message.InvalidISBN10: catalog.String(message.InvalidISBN10), message.InvalidISBN13: catalog.String(message.InvalidISBN13), + message.InvalidCurrency: catalog.String(message.InvalidCurrency), message.InvalidCIDR: catalog.String(message.InvalidCIDR), message.CIDRNetmaskOutOfRange: catalog.String(message.CIDRNetmaskOutOfRange), message.InvalidIP: catalog.String(message.InvalidIP), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 6b9e7b1..0b27052 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -65,6 +65,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidISBN: catalog.String("Значение не является допустимым ISBN-10 или ISBN-13."), message.InvalidISBN10: catalog.String("Значение не является допустимым ISBN-10."), message.InvalidISBN13: catalog.String("Значение не является допустимым ISBN-13."), + message.InvalidCurrency: catalog.String("Значение не является допустимым кодом валюты."), message.InvalidCIDR: catalog.String("Значение не является допустимой записью CIDR."), message.CIDRNetmaskOutOfRange: catalog.String("Значение маски сети должно быть между {{ min }} и {{ max }}."), message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."), diff --git a/test/constraints_identifiers_cases_test.go b/test/constraints_identifiers_cases_test.go index 1dbe6cb..7200850 100644 --- a/test/constraints_identifiers_cases_test.go +++ b/test/constraints_identifiers_cases_test.go @@ -9,6 +9,7 @@ import ( var identifierConstraintsTestCases = mergeTestCases( ulidConstraintTestCases, uuidConstraintTestCases, + currencyConstraintTestCases, ibanConstraintTestCases, bicConstraintTestCases, isinConstraintTestCases, @@ -17,6 +18,77 @@ var identifierConstraintsTestCases = mergeTestCases( luhnConstraintTestCases, ) +var currencyConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "IsCurrency passes on empty value", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsCurrency(), + stringValue: stringValue(""), + assert: assertNoError, + }, + { + name: "IsCurrency passes on valid ISO code", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("EUR"), + constraint: it.IsCurrency(), + assert: assertNoError, + }, + { + name: "IsCurrency passes on lowercase code", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("usd"), + constraint: it.IsCurrency(), + assert: assertNoError, + }, + { + name: "IsCurrency violation on unknown code", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("ZZZ"), + constraint: it.IsCurrency(), + assert: assertHasOneViolation(validation.ErrInvalidCurrency, message.InvalidCurrency), + }, + { + name: "IsCurrency violation on wrong length", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("EU"), + constraint: it.IsCurrency(), + assert: assertHasOneViolation(validation.ErrInvalidCurrency, message.InvalidCurrency), + }, + { + name: "IsCurrency violation with custom error and message", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsCurrency(). + WithError(ErrCustom). + WithMessage( + `Invalid value "{{ value }}" for {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}, + ), + stringValue: stringValue("UUU"), + assert: assertHasOneViolation(ErrCustom, `Invalid value "UUU" for parameter.`), + }, + { + name: "IsCurrency passes when condition is false", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsCurrency().When(false), + stringValue: stringValue("ZZZ"), + assert: assertNoError, + }, + { + name: "IsCurrency violation when condition is true", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsCurrency().When(true), + stringValue: stringValue("ZZZ"), + assert: assertHasOneViolation(validation.ErrInvalidCurrency, message.InvalidCurrency), + }, + { + name: "IsCurrency passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsCurrency().WhenGroups(testGroup), + stringValue: stringValue("ZZZ"), + assert: assertNoError, + }, +} + var ulidConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsULID passes on valid value", diff --git a/validate/currency.go b/validate/currency.go new file mode 100644 index 0000000..e6eef40 --- /dev/null +++ b/validate/currency.go @@ -0,0 +1,29 @@ +package validate + +import ( + "errors" + + "golang.org/x/text/currency" +) + +// ErrInvalidCurrency is returned by [Currency] when the value is not a valid ISO 4217 currency code. +var ErrInvalidCurrency = errors.New("invalid currency") + +// Currency validates whether the value is a recognized ISO 4217 alphabetic currency code (three letters). +// Letter case is normalized the same way as [golang.org/x/text/currency.ParseISO] (all upper or all lower). +// +// Empty string is considered valid (use [NotBlank] or similar to reject empty values). +// +// Possible errors: +// - [ErrInvalidCurrency] when the string is not exactly three letters, is malformed, or is not a known code. +// +// See https://www.iso.org/iso-4217-currency-codes.html and [golang.org/x/text/currency.ParseISO]. +func Currency(value string) error { + if value == "" { + return nil + } + if _, err := currency.ParseISO(value); err != nil { + return ErrInvalidCurrency + } + return nil +} diff --git a/validate/currency_test.go b/validate/currency_test.go new file mode 100644 index 0000000..10917e1 --- /dev/null +++ b/validate/currency_test.go @@ -0,0 +1,40 @@ +package validate_test + +import ( + "errors" + "testing" + + "github.com/muonsoft/validation/validate" +) + +func TestCurrency(t *testing.T) { + tests := []struct { + name string + value string + wantErr error + }{ + {name: "empty", value: "", wantErr: nil}, + {name: "EUR upper", value: "EUR", wantErr: nil}, + {name: "usd lower", value: "usd", wantErr: nil}, + {name: "chf mixed case normalizes", value: "CHf", wantErr: nil}, + {name: "too short", value: "EU", wantErr: validate.ErrInvalidCurrency}, + {name: "too long", value: "EURO", wantErr: validate.ErrInvalidCurrency}, + {name: "unknown", value: "ZZZ", wantErr: validate.ErrInvalidCurrency}, + {name: "digits", value: "123", wantErr: validate.ErrInvalidCurrency}, + {name: "space", value: "EUR ", wantErr: validate.ErrInvalidCurrency}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Currency(tt.value) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("Currency(%q): %v", tt.value, err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Currency(%q): got %v, want %v", tt.value, err, tt.wantErr) + } + }) + } +} diff --git a/validate/example_test.go b/validate/example_test.go index 5939126..e3ad9c5 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -180,6 +180,16 @@ func ExampleIBAN() { // invalid IBAN } +func ExampleCurrency() { + fmt.Println(validate.Currency("EUR")) + fmt.Println(validate.Currency("ZZZ")) + fmt.Println(validate.Currency("EU")) + // Output: + // + // invalid currency + // invalid currency +} + func ExampleBIC() { fmt.Println(validate.BIC("DEUTDEFF")) fmt.Println(validate.BIC("DEUTDEF"))