Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions is/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
8 changes: 8 additions & 0 deletions is/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <nil>
}

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)
Expand Down
11 changes: 11 additions & 0 deletions it/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions message/translations/english/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions message/translations/russian/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 адресом."),
Expand Down
72 changes: 72 additions & 0 deletions test/constraints_identifiers_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
var identifierConstraintsTestCases = mergeTestCases(
ulidConstraintTestCases,
uuidConstraintTestCases,
currencyConstraintTestCases,
ibanConstraintTestCases,
bicConstraintTestCases,
isinConstraintTestCases,
Expand All @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions validate/currency.go
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions validate/currency_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
10 changes: 10 additions & 0 deletions validate/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <nil>
// invalid currency
// invalid currency
}

func ExampleBIC() {
fmt.Println(validate.BIC("DEUTDEFF"))
fmt.Println(validate.BIC("DEUTDEF"))
Expand Down
Loading