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

- 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`).
- 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`).
Expand Down
3 changes: 3 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ var (
ErrBICIBANCountryMismatch = NewError("BIC IBAN country mismatch", message.BICNotAssociatedWithIBAN)
ErrInvalidISIN = NewError("invalid ISIN", message.InvalidISIN)
ErrInvalidISSN = NewError("invalid ISSN", message.InvalidISSN)
ErrInvalidISBN = NewError("invalid ISBN", message.InvalidISBN)
ErrInvalidISBN10 = NewError("invalid ISBN-10", message.InvalidISBN10)
ErrInvalidISBN13 = NewError("invalid ISBN-13", message.InvalidISBN13)
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 @@ -309,6 +309,16 @@ func ExampleISSN() {
// false
}

func ExampleISBN() {
fmt.Println(is.ISBN("978-0451225245"))
fmt.Println(is.ISBN("978272344228"))
fmt.Println(is.ISBN("1234567890"))
// Output:
// 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 @@ -40,6 +40,14 @@ func ISSN(value string) bool {
return validate.ISSN(value) == nil
}

// ISBN validates whether the value is a valid ISBN-10 or ISBN-13.
// See [github.com/muonsoft/validation/validate.ISBN] for options and possible errors.
//
// See https://en.wikipedia.org/wiki/ISBN.
func ISBN(value string, options ...func(*validate.ISBNOptions)) bool {
return validate.ISBN(value, options...) == 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 @@ -97,6 +97,20 @@ func ExampleIsISSN_invalid() {
// violation: "This value is not a valid ISSN."
}

func ExampleIsISBN_valid() {
err := validator.Validate(context.Background(), validation.String("978-0451225245", it.IsISBN()))
fmt.Println(err)
// Output:
// <nil>
}

func ExampleIsISBN_invalid() {
err := validator.Validate(context.Background(), validation.String("978272344228", it.IsISBN()))
fmt.Println(err)
// Output:
// violation: "This value is neither a valid ISBN-10 nor a valid ISBN-13."
}

func ExampleIsLUHN_valid() {
err := validator.Validate(context.Background(), validation.String("79927398713", it.IsLUHN()))
fmt.Println(err)
Expand Down
174 changes: 174 additions & 0 deletions it/isbn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package it

import (
"context"

"github.com/muonsoft/validation"
"github.com/muonsoft/validation/validate"
)

type isbnMode int

const (
isbnModeAny isbnMode = iota
isbnMode10
isbnMode13
)

// ISBNConstraint validates whether a string value is a valid ISBN-10 or ISBN-13.
// Hyphens (U+002D) are stripped before validation, matching Symfony Isbn.
//
// By default, either ISBN-10 or ISBN-13 is accepted. Use [ISBNConstraint.Only10] or
// [ISBNConstraint.Only13] to restrict the format.
//
// Empty values are skipped; combine with [NotBlank] or similar to reject empty strings.
//
// Behavior is aligned with Symfony\Component\Validator\Constraints\Isbn.
//
// See https://en.wikipedia.org/wiki/ISBN.
type ISBNConstraint struct {
isIgnored bool
groups []string
mode isbnMode
options []func(*validate.ISBNOptions)
err10 error
err13 error
errBoth error
message10 string
message13 string
messageBoth string
messageParams validation.TemplateParameterList
}

// IsISBN validates whether the value is a valid ISBN-10 or ISBN-13.
func IsISBN() ISBNConstraint {
return ISBNConstraint{
err10: validation.ErrInvalidISBN10,
err13: validation.ErrInvalidISBN13,
errBoth: validation.ErrInvalidISBN,
message10: validation.ErrInvalidISBN10.Message(),
message13: validation.ErrInvalidISBN13.Message(),
messageBoth: validation.ErrInvalidISBN.Message(),
}
}

// Only10 restricts validation to ISBN-10 (Symfony Isbn::ISBN_10).
func (c ISBNConstraint) Only10() ISBNConstraint {
c.mode = isbnMode10
c.options = append(c.options, validate.ISBNOnly10())
return c
}

// Only13 restricts validation to ISBN-13 (Symfony Isbn::ISBN_13).
func (c ISBNConstraint) Only13() ISBNConstraint {
c.mode = isbnMode13
c.options = append(c.options, validate.ISBNOnly13())
return c
}

// WithError overrides the default errors for all ISBN modes.
func (c ISBNConstraint) WithError(err error) ISBNConstraint {
c.err10 = err
c.err13 = err
c.errBoth = err
return c
}

// WithISBN10Error overrides the error used when validating as ISBN-10 only.
func (c ISBNConstraint) WithISBN10Error(err error) ISBNConstraint {
c.err10 = err
return c
}

// WithISBN13Error overrides the error used when validating as ISBN-13 only.
func (c ISBNConstraint) WithISBN13Error(err error) ISBNConstraint {
c.err13 = err
return c
}

// WithBothISBNError overrides the error used when either ISBN-10 or ISBN-13 is accepted.
func (c ISBNConstraint) WithBothISBNError(err error) ISBNConstraint {
c.errBoth = err
return c
}

// WithMessage sets the message template for all ISBN modes.
func (c ISBNConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) ISBNConstraint {
c.message10 = template
c.message13 = template
c.messageBoth = template
c.messageParams = parameters
return c
}

// WithISBN10Message sets the message template for ISBN-10-only validation.
func (c ISBNConstraint) WithISBN10Message(template string, parameters ...validation.TemplateParameter) ISBNConstraint {
c.message10 = template
c.messageParams = parameters
return c
}

// WithISBN13Message sets the message template for ISBN-13-only validation.
func (c ISBNConstraint) WithISBN13Message(template string, parameters ...validation.TemplateParameter) ISBNConstraint {
c.message13 = template
c.messageParams = parameters
return c
}

// WithBothISBNMessage sets the message template when either format is accepted.
func (c ISBNConstraint) WithBothISBNMessage(template string, parameters ...validation.TemplateParameter) ISBNConstraint {
c.messageBoth = template
c.messageParams = parameters
return c
}

// When enables conditional validation of this constraint.
func (c ISBNConstraint) When(condition bool) ISBNConstraint {
c.isIgnored = !condition
return c
}

// WhenGroups enables conditional validation by validation groups.
func (c ISBNConstraint) WhenGroups(groups ...string) ISBNConstraint {
c.groups = groups
return c
}

func (c ISBNConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error {
if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" {
return nil
}

err := validate.ISBN(*value, c.options...)
if err == nil {
return nil
}

var vErr error
var msg string

switch c.mode {
case isbnMode10:
vErr = c.err10
msg = c.message10
case isbnMode13:
vErr = c.err13
msg = c.message13
default:
vErr = c.errBoth
msg = c.messageBoth
}

return validator.BuildViolation(ctx, vErr, msg).
WithParameters(
c.messageParams.Prepend(
validation.TemplateParameter{Key: "{{ value }}", Value: *value},
)...,
).
Create()
}

// Validate implements [validation.Constraint][string].
func (c ISBNConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error {
return c.ValidateString(ctx, validator, &v)
}
3 changes: 3 additions & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const (
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."
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."
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
3 changes: 3 additions & 0 deletions message/translations/english/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.BICNotAssociatedWithIBAN: catalog.String(message.BICNotAssociatedWithIBAN),
message.InvalidISIN: catalog.String(message.InvalidISIN),
message.InvalidISSN: catalog.String(message.InvalidISSN),
message.InvalidISBN: catalog.String(message.InvalidISBN),
message.InvalidISBN10: catalog.String(message.InvalidISBN10),
message.InvalidISBN13: catalog.String(message.InvalidISBN13),
message.InvalidCIDR: catalog.String(message.InvalidCIDR),
message.CIDRNetmaskOutOfRange: catalog.String(message.CIDRNetmaskOutOfRange),
message.InvalidIP: catalog.String(message.InvalidIP),
Expand Down
3 changes: 3 additions & 0 deletions message/translations/russian/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.BICNotAssociatedWithIBAN: catalog.String("Этот банковский идентификатор (BIC) не соответствует IBAN {{ iban }}."),
message.InvalidISIN: catalog.String("Значение не является допустимым международным идентификационным номером ценной бумаги (ISIN)."),
message.InvalidISSN: catalog.String("Значение не является допустимым ISSN."),
message.InvalidISBN: catalog.String("Значение не является допустимым ISBN-10 или ISBN-13."),
message.InvalidISBN10: catalog.String("Значение не является допустимым ISBN-10."),
message.InvalidISBN13: catalog.String("Значение не является допустимым ISBN-13."),
message.InvalidCIDR: catalog.String("Значение не является допустимой записью CIDR."),
message.CIDRNetmaskOutOfRange: catalog.String("Значение маски сети должно быть между {{ min }} и {{ max }}."),
message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."),
Expand Down
86 changes: 86 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,
isbnConstraintTestCases,
issnConstraintTestCases,
luhnConstraintTestCases,
)
Expand Down Expand Up @@ -297,6 +298,91 @@ var isinConstraintTestCases = []ConstraintValidationTestCase{
},
}

var isbnConstraintTestCases = []ConstraintValidationTestCase{
{
name: "IsISBN passes on empty value",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsISBN(),
stringValue: stringValue(""),
assert: assertNoError,
},
{
name: "IsISBN passes on valid ISBN-10",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("0-45122-5244"),
constraint: it.IsISBN(),
assert: assertNoError,
},
{
name: "IsISBN passes on valid ISBN-13",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("978-0451225245"),
constraint: it.IsISBN(),
assert: assertNoError,
},
{
name: "IsISBN violation on neither format",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("978272344228"),
constraint: it.IsISBN(),
assert: assertHasOneViolation(validation.ErrInvalidISBN, message.InvalidISBN),
},
{
name: "IsISBN Only10 passes",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("0321812700"),
constraint: it.IsISBN().Only10(),
assert: assertNoError,
},
{
name: "IsISBN Only10 violation uses ISBN-10 message",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("1234567890"),
constraint: it.IsISBN().Only10(),
assert: assertHasOneViolation(validation.ErrInvalidISBN10, message.InvalidISBN10),
},
{
name: "IsISBN Only13 violation uses ISBN-13 message",
isApplicableFor: specificValueTypes(stringType),
stringValue: stringValue("2723442284"),
constraint: it.IsISBN().Only13(),
assert: assertHasOneViolation(validation.ErrInvalidISBN13, message.InvalidISBN13),
},
{
name: "IsISBN violation with given error and message",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsISBN().
WithError(ErrCustom).
WithMessage(
`Invalid value "{{ value }}" for {{ custom }}.`,
validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"},
),
stringValue: stringValue("bad-isbn"),
assert: assertHasOneViolation(ErrCustom, `Invalid value "bad-isbn" for parameter.`),
},
{
name: "IsISBN passes when condition is false",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsISBN().When(false),
stringValue: stringValue("bad"),
assert: assertNoError,
},
{
name: "IsISBN violation when condition is true",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsISBN().When(true),
stringValue: stringValue("bad"),
assert: assertHasOneViolation(validation.ErrInvalidISBN, message.InvalidISBN),
},
{
name: "IsISBN passes when groups not match",
isApplicableFor: specificValueTypes(stringType),
constraint: it.IsISBN().WhenGroups(testGroup),
stringValue: stringValue("bad"),
assert: assertNoError,
},
}

var issnConstraintTestCases = []ConstraintValidationTestCase{
{
name: "IsISSN 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 @@ -204,6 +204,16 @@ func ExampleISSN() {
// invalid checksum
}

func ExampleISBN() {
fmt.Println(validate.ISBN("978-0451225245"))
fmt.Println(validate.ISBN("978272344228"))
fmt.Println(validate.ISBN("1234567890", validate.ISBNOnly10()))
// Output:
// <nil>
// ISBN type not recognized
// ISBN checksum failed
}

func ExampleLUHN() {
fmt.Println(validate.LUHN("79927398713"))
fmt.Println(validate.LUHN("79927398710"))
Expand Down
Loading
Loading