diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a4cdc..76f0e83 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 +- 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`). - **NoSuspiciousCharacters** (spoofing / homoglyph checks): `it.HasNoSuspiciousCharacters()` with `CheckInvisible` / `CheckMixedNumbers` / `CheckHiddenOverlay`, `WithoutInvisible` / `WithoutMixedNumbers` / `WithoutHiddenOverlay` (bitmask-based), locale and single-script restrictions; `validate.NoSuspiciousCharacters`; errors `validation.ErrSuspiciousInvisible`, `ErrSuspiciousMixedNumbers`, `ErrSuspiciousHiddenOverlay`, `ErrSuspiciousCharactersRestriction` and English/Russian messages (behavior inspired by Symfony `NoSuspiciousCharacters`, implemented without CGO; may differ from ICU in edge cases). diff --git a/errors.go b/errors.go index f1e2ca7..f38fb4d 100644 --- a/errors.go +++ b/errors.go @@ -19,6 +19,7 @@ var ( ErrInvalidBIC = NewError("invalid BIC", message.InvalidBIC) ErrBICIBANCountryMismatch = NewError("BIC IBAN country mismatch", message.BICNotAssociatedWithIBAN) ErrInvalidISIN = NewError("invalid ISIN", message.InvalidISIN) + ErrInvalidISSN = NewError("invalid ISSN", message.InvalidISSN) 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 f76cb6c..b658235 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -295,6 +295,20 @@ func ExampleISIN() { // false } +func ExampleISSN() { + fmt.Println(is.ISSN("0317-8471")) + fmt.Println(is.ISSN("03178471")) + fmt.Println(is.ISSN("2434-561X")) + fmt.Println(is.ISSN("0317-8470")) + fmt.Println(is.ISSN("123-45678")) + // Output: + // true + // true + // true + // false + // false +} + func ExampleLUHN() { fmt.Println(is.LUHN("79927398713")) fmt.Println(is.LUHN("79927398710")) diff --git a/is/identifiers.go b/is/identifiers.go index 92ecf77..c672ab5 100644 --- a/is/identifiers.go +++ b/is/identifiers.go @@ -32,6 +32,14 @@ func ISIN(value string) bool { return validate.ISIN(value) == nil } +// ISSN validates whether the value is a valid International Standard Serial Number (ISSN). +// See [github.com/muonsoft/validation/validate.ISSN] for validation rules and possible errors. +// +// See https://www.issn.org/understanding-the-issn/what-is-an-issn/. +func ISSN(value string) bool { + return validate.ISSN(value) == nil +} + // LUHN validates whether the value passes the Luhn (mod 10) checksum. // See [github.com/muonsoft/validation/validate.LUHN] for validation rules and possible errors. // diff --git a/it/example_test.go b/it/example_test.go index bfa207b..12cecb7 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -83,6 +83,20 @@ func ExampleIsISIN_invalid() { // violation: "This value is not a valid International Securities Identification Number (ISIN)." } +func ExampleIsISSN_valid() { + err := validator.Validate(context.Background(), validation.String("0317-8471", it.IsISSN())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsISSN_invalid() { + err := validator.Validate(context.Background(), validation.String("0317-8470", it.IsISSN())) + fmt.Println(err) + // Output: + // violation: "This value is not a valid ISSN." +} + func ExampleIsLUHN_valid() { err := validator.Validate(context.Background(), validation.String("79927398713", it.IsLUHN())) fmt.Println(err) diff --git a/it/identifiers.go b/it/identifiers.go index ac26cf9..c61e53a 100644 --- a/it/identifiers.go +++ b/it/identifiers.go @@ -34,6 +34,16 @@ func IsISIN() validation.StringFuncConstraint { WithMessage(validation.ErrInvalidISIN.Message()) } +// IsISSN validates whether the value is a valid International Standard Serial Number (ISSN). +// Behavior is aligned with Symfony\Component\Validator\Constraints\Issn. +// +// See ISO 3297 and https://www.issn.org/understanding-the-issn/what-is-an-issn/. +func IsISSN() validation.StringFuncConstraint { + return validation.OfStringBy(is.ISSN). + WithError(validation.ErrInvalidISSN). + WithMessage(validation.ErrInvalidISSN.Message()) +} + // IsLUHN validates whether the value passes the Luhn (mod 10) checksum, as in // Symfony\Component\Validator\Constraints\Luhn. // diff --git a/message/messages.go b/message/messages.go index 1c8e73d..0438f90 100644 --- a/message/messages.go +++ b/message/messages.go @@ -37,6 +37,7 @@ const ( InvalidBIC = "This is not a valid Business Identifier Code (BIC)." BICNotAssociatedWithIBAN = "This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}." InvalidISIN = "This value is not a valid International Securities Identification Number (ISIN)." + InvalidISSN = "This value is not a valid ISSN." 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 417d9ee..5eb9e72 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -58,6 +58,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidBIC: catalog.String(message.InvalidBIC), message.BICNotAssociatedWithIBAN: catalog.String(message.BICNotAssociatedWithIBAN), message.InvalidISIN: catalog.String(message.InvalidISIN), + message.InvalidISSN: catalog.String(message.InvalidISSN), 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 3732690..aa17591 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -61,6 +61,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidBIC: catalog.String("Значение не является допустимым банковским идентификатором (BIC)."), message.BICNotAssociatedWithIBAN: catalog.String("Этот банковский идентификатор (BIC) не соответствует IBAN {{ iban }}."), message.InvalidISIN: catalog.String("Значение не является допустимым международным идентификационным номером ценной бумаги (ISIN)."), + message.InvalidISSN: catalog.String("Значение не является допустимым ISSN."), 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 fb46d63..af04e11 100644 --- a/test/constraints_identifiers_cases_test.go +++ b/test/constraints_identifiers_cases_test.go @@ -12,6 +12,7 @@ var identifierConstraintsTestCases = mergeTestCases( ibanConstraintTestCases, bicConstraintTestCases, isinConstraintTestCases, + issnConstraintTestCases, luhnConstraintTestCases, ) @@ -296,6 +297,86 @@ var isinConstraintTestCases = []ConstraintValidationTestCase{ }, } +var issnConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "IsISSN passes on empty value", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISSN(), + stringValue: stringValue(""), + assert: assertNoError, + }, + { + name: "IsISSN passes on valid value with hyphen", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("0317-8471"), + constraint: it.IsISSN(), + assert: assertNoError, + }, + { + name: "IsISSN passes on valid value without hyphen", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("03178471"), + constraint: it.IsISSN(), + assert: assertNoError, + }, + { + name: "IsISSN passes on check digit X", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2434-561X"), + constraint: it.IsISSN(), + assert: assertNoError, + }, + { + name: "IsISSN passes on lowercase check digit x", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2434-561x"), + constraint: it.IsISSN(), + assert: assertNoError, + }, + { + name: "IsISSN violation on wrong length", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("0317-847"), + constraint: it.IsISSN(), + assert: assertHasOneViolation(validation.ErrInvalidISSN, message.InvalidISSN), + }, + { + name: "IsISSN violation on invalid hyphen placement", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("123-45678"), + constraint: it.IsISSN(), + assert: assertHasOneViolation(validation.ErrInvalidISSN, message.InvalidISSN), + }, + { + name: "IsISSN violation on invalid checksum", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("0317-8470"), + constraint: it.IsISSN(), + assert: assertHasOneViolation(validation.ErrInvalidISSN, message.InvalidISSN), + }, + { + name: "IsISSN passes when condition is false", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISSN().When(false), + stringValue: stringValue("bad"), + assert: assertNoError, + }, + { + name: "IsISSN violation when condition is true", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISSN().When(true), + stringValue: stringValue("bad"), + assert: assertHasOneViolation(validation.ErrInvalidISSN, message.InvalidISSN), + }, + { + name: "IsISSN passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISSN().WhenGroups(testGroup), + stringValue: stringValue("bad"), + assert: assertNoError, + }, +} + var luhnConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsLUHN passes on empty value", diff --git a/validate/example_test.go b/validate/example_test.go index 553f829..23631fe 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -192,6 +192,18 @@ func ExampleISIN() { // invalid checksum } +func ExampleISSN() { + fmt.Println(validate.ISSN("0317-8471")) + fmt.Println(validate.ISSN("0317-847")) + fmt.Println(validate.ISSN("123-45678")) + fmt.Println(validate.ISSN("0317-8470")) + // Output: + // + // too short + // invalid characters + // invalid checksum +} + func ExampleLUHN() { fmt.Println(validate.LUHN("79927398713")) fmt.Println(validate.LUHN("79927398710")) diff --git a/validate/identifiers.go b/validate/identifiers.go index a9deedc..bc9923c 100644 --- a/validate/identifiers.go +++ b/validate/identifiers.go @@ -24,6 +24,9 @@ var ulidChars = newCharSet("0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwx // Same pattern as Symfony Isin::VALIDATION_PATTERN (length is checked separately). var isinPattern = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}[0-9]$`) +// Same pattern as Symfony Issn::PATTERN (optional hyphen between the two groups). +var issnPattern = regexp.MustCompile(`^[0-9]{4}-?[0-9]{3}[0-9Xx]$`) + // ULID validates whether the value is a valid ULID (Universally Unique Lexicographically Sortable Identifier). // See https://github.com/ulid/spec for ULID specifications. // @@ -85,6 +88,58 @@ func ISIN(value string) error { return nil } +// ISSN validates whether the value is a valid International Standard Serial Number (ISSN). +// The check digit uses the ISO 3297 mod 11 algorithm (weights 8…2 on the first seven digits). +// An optional hyphen is allowed between the two four-character groups, matching +// Symfony\Component\Validator\Constraints\Issn. +// +// Possible errors: +// - [ErrTooShort] when fewer than eight digits/check characters remain after removing hyphens; +// - [ErrTooLong] when more than eight remain; +// - [ErrInvalidCharacters] when the format is not NNNN-NNNC (with optional hyphen) or digits are invalid; +// - [ErrInvalidChecksum] when the check character is wrong; +// +// See https://www.issn.org/understanding-the-issn/what-is-an-issn/ and ISO 3297. +func ISSN(value string) error { + if issnPattern.MatchString(value) { + return issnValidateBody(strings.ToUpper(strings.ReplaceAll(value, "-", ""))) + } + s := strings.ReplaceAll(value, "-", "") + switch { + case len(s) < 8: + return ErrTooShort + case len(s) > 8: + return ErrTooLong + default: + return ErrInvalidCharacters + } +} + +func issnValidateBody(canonical string) error { + var sum int + for i := 0; i < 7; i++ { + c := canonical[i] + if c < '0' || c > '9' { + return ErrInvalidCharacters + } + sum += int(c-'0') * (8 - i) + } + check := 11 - (sum % 11) + if check == 11 { + check = 0 + } + var expected byte + if check < 10 { + expected = byte('0' + check) + } else { + expected = 'X' + } + if canonical[7] != expected { + return ErrInvalidChecksum + } + return nil +} + func isinCharValue(c byte) int { switch { case c >= '0' && c <= '9': diff --git a/validate/identifiers_test.go b/validate/identifiers_test.go index 127ea8f..679b297 100644 --- a/validate/identifiers_test.go +++ b/validate/identifiers_test.go @@ -7,6 +7,36 @@ import ( "github.com/stretchr/testify/assert" ) +func TestISSN(t *testing.T) { + tests := []struct { + value string + expectedError error + }{ + {value: "0317-8471"}, + {value: "03178471"}, + {value: "2434-561X"}, + {value: "2434-561x"}, + {value: "", expectedError: validate.ErrTooShort}, + {value: "0317-847", expectedError: validate.ErrTooShort}, + {value: "031784712", expectedError: validate.ErrTooLong}, + {value: "1234-567890", expectedError: validate.ErrTooLong}, + {value: "123-45678", expectedError: validate.ErrInvalidCharacters}, + {value: "1234-567y", expectedError: validate.ErrInvalidCharacters}, + {value: "0317-8470", expectedError: validate.ErrInvalidChecksum}, + } + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + err := validate.ISSN(test.value) + + if test.expectedError == nil { + assert.NoError(t, err) + } else { + assert.ErrorIs(t, err, test.expectedError) + } + }) + } +} + func TestISIN(t *testing.T) { tests := []struct { value string