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

- 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`).
- CIDR notation validation: `it.IsCIDR()` with `IPv4Only`, `IPv6Only`, `WithVersion`, `WithNetmaskRange`, and separate invalid vs netmask-range messages; `validate.CIDR`, `validate.CIDRViolationNetmaskBounds`, `is.CIDR`; `validation.ErrInvalidCIDR` / `validation.ErrCIDRNetmaskOutOfRange` and English and Russian translations (behavior aligned with Symfony `Cidr`). Exported names use the **CIDR** initialism per [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments).
- LUHN (mod 10 / Luhn) checksum validation: `it.IsLUHN()`, `validate.LUHN`, `is.LUHN`, with `validation.ErrInvalidLUHN` / `message.InvalidLUHN` and English and Russian translations (behavior aligned with Symfony `Luhn`).
- 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`).
Expand Down
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ var (
ErrInvalidEAN8 = NewError("invalid EAN-8", message.InvalidEAN8)
ErrInvalidEmail = NewError("invalid email", message.InvalidEmail)
ErrInvalidHostname = NewError("invalid hostname", message.InvalidHostname)
ErrInvalidIBAN = NewError("invalid IBAN", message.InvalidIBAN)
ErrInvalidISIN = NewError("invalid ISIN", message.InvalidISIN)
ErrInvalidCIDR = NewError("invalid CIDR", message.InvalidCIDR)
ErrCIDRNetmaskOutOfRange = NewError("CIDR netmask out of range", message.CIDRNetmaskOutOfRange)
Expand Down
10 changes: 10 additions & 0 deletions is/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ func ExampleULID() {
// false
}

func ExampleIBAN() {
fmt.Println(is.IBAN("DE89370400440532013000"))
fmt.Println(is.IBAN("DE89370400440532013001"))
fmt.Println(is.IBAN("US64SVBX1101057138"))
// Output:
// true
// false
// false
}

func ExampleISIN() {
fmt.Println(is.ISIN("US0378331005"))
fmt.Println(is.ISIN("US037833100"))
Expand Down
8 changes: 8 additions & 0 deletions is/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ func ULID(value string) bool {
return validate.ULID(value) == nil
}

// IBAN validates whether the value is a valid International Bank Account Number (IBAN).
// See [github.com/muonsoft/validation/validate.IBAN] for validation rules.
//
// See https://en.wikipedia.org/wiki/International_Bank_Account_Number.
func IBAN(value string) bool {
return validate.IBAN(value) == nil
}

// ISIN validates whether the value is a valid International Securities Identification Number (ISIN).
// See [github.com/muonsoft/validation/validate.ISIN] for validation rules and possible errors.
//
Expand Down
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ func ExampleIsUPCE() {
// violation: "This value is not a valid UPC-E."
}

func ExampleIsIBAN_valid() {
err := validator.Validate(context.Background(), validation.String("DE89370400440532013000", it.IsIBAN()))
fmt.Println(err)
// Output:
// <nil>
}

func ExampleIsIBAN_invalid() {
err := validator.Validate(context.Background(), validation.String("DE89370400440532013001", it.IsIBAN()))
fmt.Println(err)
// Output:
// violation: "This is not a valid International Bank Account Number (IBAN)."
}

func ExampleIsISIN_valid() {
err := validator.Validate(context.Background(), validation.String("US0378331005", it.IsISIN()))
fmt.Println(err)
Expand Down
10 changes: 10 additions & 0 deletions it/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ func IsULID() validation.StringFuncConstraint {
WithMessage(validation.ErrInvalidULID.Message())
}

// IsIBAN validates whether the value is a valid International Bank Account Number (IBAN).
// Behavior is aligned with Symfony\Component\Validator\Constraints\Iban.
//
// See https://en.wikipedia.org/wiki/International_Bank_Account_Number.
func IsIBAN() validation.StringFuncConstraint {
return validation.OfStringBy(is.IBAN).
WithError(validation.ErrInvalidIBAN).
WithMessage(validation.ErrInvalidIBAN.Message())
}

// IsISIN validates whether the value is a valid International Securities Identification Number (ISIN).
// See https://en.wikipedia.org/wiki/International_Securities_Identification_Number.
func IsISIN() validation.StringFuncConstraint {
Expand Down
1 change: 1 addition & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
InvalidEAN8 = "This value is not a valid EAN-8."
InvalidEmail = "This value is not a valid email address."
InvalidHostname = "This value is not a valid hostname."
InvalidIBAN = "This is not a valid International Bank Account Number (IBAN)."
InvalidISIN = "This value is not a valid International Securities Identification Number (ISIN)."
InvalidCIDR = "This value is not a valid CIDR notation."
CIDRNetmaskOutOfRange = "The value of the netmask should be between {{ min }} and {{ max }}."
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 @@ -54,6 +54,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidEAN8: catalog.String(message.InvalidEAN8),
message.InvalidEmail: catalog.String(message.InvalidEmail),
message.InvalidHostname: catalog.String(message.InvalidHostname),
message.InvalidIBAN: catalog.String(message.InvalidIBAN),
message.InvalidISIN: catalog.String(message.InvalidISIN),
message.InvalidCIDR: catalog.String(message.InvalidCIDR),
message.CIDRNetmaskOutOfRange: catalog.String(message.CIDRNetmaskOutOfRange),
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 @@ -57,6 +57,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidEAN8: catalog.String("Значение не является допустимым EAN-8."),
message.InvalidEmail: catalog.String("Значение адреса электронной почты недопустимо."),
message.InvalidHostname: catalog.String("Значение не является корректным именем хоста."),
message.InvalidIBAN: catalog.String("Значение не является допустимым международным номером банковского счёта (IBAN)."),
message.InvalidISIN: catalog.String("Значение не является допустимым международным идентификационным номером ценной бумаги (ISIN)."),
message.InvalidCIDR: catalog.String("Значение не является допустимой записью CIDR."),
message.CIDRNetmaskOutOfRange: catalog.String("Значение маски сети должно быть между {{ min }} и {{ max }}."),
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,
ibanConstraintTestCases,
isinConstraintTestCases,
luhnConstraintTestCases,
)
Expand All @@ -30,6 +31,77 @@ var ulidConstraintTestCases = []ConstraintValidationTestCase{
},
}

var ibanConstraintTestCases = []ConstraintValidationTestCase{
{
name: "IsIBAN passes on empty value",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsIBAN(),
stringValue: stringValue(""),
assert: assertNoError,
},
{
name: "IsIBAN passes on valid value",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("DE89370400440532013000"),
constraint: it.IsIBAN(),
assert: assertNoError,
},
{
name: "IsIBAN passes on spaced value",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("CH93 0076 2011 6238 5295 7"),
constraint: it.IsIBAN(),
assert: assertNoError,
},
{
name: "IsIBAN violation on invalid checksum",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("DE89370400440532013001"),
constraint: it.IsIBAN(),
assert: assertHasOneViolation(validation.ErrInvalidIBAN, message.InvalidIBAN),
},
{
name: "IsIBAN violation on unsupported country",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("US64SVBX1101057138"),
constraint: it.IsIBAN(),
assert: assertHasOneViolation(validation.ErrInvalidIBAN, message.InvalidIBAN),
},
{
name: "IsIBAN violation with given error and message",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsIBAN().
WithError(ErrCustom).
WithMessage(
`Invalid value "{{ value }}" for {{ custom }}.`,
validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"},
),
stringValue: stringValue("bad-iban"),
assert: assertHasOneViolation(ErrCustom, `Invalid value "bad-iban" for parameter.`),
},
{
name: "IsIBAN passes when condition is false",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsIBAN().When(false),
stringValue: stringValue("bad"),
assert: assertNoError,
},
{
name: "IsIBAN violation when condition is true",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsIBAN().When(true),
stringValue: stringValue("bad"),
assert: assertHasOneViolation(validation.ErrInvalidIBAN, message.InvalidIBAN),
},
{
name: "IsIBAN passes when groups not match",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsIBAN().WhenGroups(testGroup),
stringValue: stringValue("bad"),
assert: assertNoError,
},
}

var isinConstraintTestCases = []ConstraintValidationTestCase{
{
name: "IsISIN passes on empty value",
Expand Down
10 changes: 10 additions & 0 deletions validate/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ func ExampleULID() {
// too large
}

func ExampleIBAN() {
fmt.Println(validate.IBAN("DE89370400440532013000"))
fmt.Println(validate.IBAN("DE89370400440532013001"))
fmt.Println(validate.IBAN("US64SVBX1101057138"))
// Output:
// <nil>
// invalid IBAN
// invalid IBAN
}

func ExampleISIN() {
fmt.Println(validate.ISIN("US0378331005"))
fmt.Println(validate.ISIN("US037833100"))
Expand Down
172 changes: 172 additions & 0 deletions validate/iban.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package validate

import (
"errors"
"strconv"
"strings"
)

// ErrInvalidIBAN is returned by [IBAN] when the value is not a valid International Bank Account Number.
// Behavior is aligned with Symfony\Component\Validator\Constraints\Iban and IbanValidator.
var ErrInvalidIBAN = errors.New("invalid IBAN")

// IBAN validates whether the value is a valid International Bank Account Number.
// Spaces (including U+00A0 and U+202F), ASCII letters, and digits are accepted; letters are normalized to upper case.
//
// Empty string is considered valid (use [NotBlank] or similar to reject empty values).
//
// Possible errors:
// - [ErrInvalidIBAN] when the value fails any IBAN rule (invalid characters, country, format, check digits, or mod-97).
//
// See https://en.wikipedia.org/wiki/International_Bank_Account_Number.
func IBAN(value string) error {
if value == "" {
return nil
}

s, ok := canonicalizeIBAN(value)
if !ok {
return ErrInvalidIBAN
}

return validateCanonicalIBAN(s)
}

func validateCanonicalIBAN(s string) error {
if len(s) < 4 {
return ErrInvalidIBAN
}
if ibanHasNonAlphanumeric(s) {
return ErrInvalidIBAN
}

cc := s[:2]
if !isAlpha2(cc) {
return ErrInvalidIBAN
}

pat, ok := ibanCountryPatterns[cc]
if !ok || pat == nil || !pat.MatchString(s) {
return ErrInvalidIBAN
}

if err := ibanCheckDigitRange(s[2:4]); err != nil {
return err
}

if ibanMod97(s) != 1 {
return ErrInvalidIBAN
}

return nil
}

func ibanHasNonAlphanumeric(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' {
continue
}
return true
}
return false
}

func ibanCheckDigitRange(two string) error {
cd, err := strconv.Atoi(two)
if err != nil || cd < 2 || cd > 98 {
return ErrInvalidIBAN
}
return nil
}

func isAlpha2(cc string) bool {
if len(cc) != 2 {
return false
}
return cc[0] >= 'A' && cc[0] <= 'Z' && cc[1] >= 'A' && cc[1] <= 'Z'
}

// canonicalizeIBAN strips IBAN grouping spaces and returns upper-case ASCII; ok is false on disallowed bytes/UTF-8.
func canonicalizeIBAN(value string) (string, bool) {
var b strings.Builder
b.Grow(len(value))

for i := 0; i < len(value); {
c := value[i]
if c == ' ' {
i++
continue
}
if n := ibanNBSPPrefix(value, i); n > 0 {
i += n
continue
}
if n := ibanNNBSPPrefix(value, i); n > 0 {
i += n
continue
}
if c >= utf8ASCIIUpperBound {
return "", false
}
if c >= 'a' && c <= 'z' {
c -= 'a' - 'A'
}
b.WriteByte(c)
i++
}

return b.String(), true
}

const utf8ASCIIUpperBound = 0x80

func ibanNBSPPrefix(value string, i int) int {
if value[i] == 0xc2 && i+1 < len(value) && value[i+1] == 0xa0 {
return 2
}
return 0
}

func ibanNNBSPPrefix(value string, i int) int {
if i+3 <= len(value) && value[i] == 0xe2 && value[i+1] == 0x80 && value[i+2] == 0xaf {
return 3
}
return 0
}

func ibanExpandedNumeric(s string) string {
rearr := s[4:] + s[:4]
var b strings.Builder
b.Grow(len(rearr) * 2)
for i := 0; i < len(rearr); i++ {
c := rearr[i]
switch {
case c >= '0' && c <= '9':
b.WriteByte(c)
case c >= 'A' && c <= 'Z':
b.WriteString(strconv.Itoa(int(c - 'A' + 10)))
default:
return ""
}
}
return b.String()
}

func ibanMod97(s string) int {
digits := ibanExpandedNumeric(s)
if digits == "" {
return 0
}
rest := 0
for start := 0; start < len(digits); start += 7 {
end := start + 7
if end > len(digits) {
end = len(digits)
}
for j := start; j < end; j++ {
rest = (rest*10 + int(digits[j]-'0')) % 97
}
}
return rest
}
Loading
Loading