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

- 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).
Expand Down
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions is/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
8 changes: 8 additions & 0 deletions is/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
14 changes: 14 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <nil>
}

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)
Expand Down
10 changes: 10 additions & 0 deletions it/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
1 change: 1 addition & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."
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 @@ -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),
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 @@ -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 адресом."),
Expand Down
81 changes: 81 additions & 0 deletions test/constraints_identifiers_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var identifierConstraintsTestCases = mergeTestCases(
ibanConstraintTestCases,
bicConstraintTestCases,
isinConstraintTestCases,
issnConstraintTestCases,
luhnConstraintTestCases,
)

Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions validate/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <nil>
// too short
// invalid characters
// invalid checksum
}

func ExampleLUHN() {
fmt.Println(validate.LUHN("79927398713"))
fmt.Println(validate.LUHN("79927398710"))
Expand Down
55 changes: 55 additions & 0 deletions validate/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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':
Expand Down
30 changes: 30 additions & 0 deletions validate/identifiers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading