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
4 changes: 2 additions & 2 deletions .cursor/skills/validation-add-constraint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Add the slice to **`validateTestCases`** in **`test/constraints_test.go`** via `

### Unit tests in `validate` for structured validation

If the constraint is implemented or configured through **`validate`** (parsers, options, several error kinds, version-specific rules), add **focused unit tests** in **`validate/*_test.go`** in addition to the shared `test/` constraint cases.
If the constraint is implemented or configured through **`validate`** (parsers, options, several error kinds, version-specific rules), add **focused unit tests** in **`validate/*_test.go`** in addition to the shared `test/` constraint cases. Use **`package validate_test`** (black-box) so tests only call the exported API unless unexported helpers must be covered.

For validations that depend on **structure** (splitting strings, numeric ranges, IPv4 vs IPv6 branches, composition of options), aim for **strong coverage**: boundary values (min/max inclusive), each error path, and helper functions used for messages (e.g. bounds for templates). Table-driven subtests work well. Shallow happy-path-only tests are easy to miss regressions for this kind of logic.

Expand Down Expand Up @@ -223,7 +223,7 @@ Add when the constraint is useful for **standalone validation** (e.g. string cod
- **Signature**: `func MyFormat(value string) error` (or with options).
- **Return**: `nil` if valid; otherwise a sentinel error from `validate` package (e.g. `ErrTooShort`, or custom).
- **Godoc**: Describe when it returns which error.
- **Tests**: `validate/*_test.go` table or cases; for non-trivial parsing or options, prefer **broad unit coverage** (see [Tests / Unit tests in validate](#4-tests-mandatory)).
- **Tests**: `validate/*_test.go` with `package validate_test` and table-driven cases; for non-trivial parsing or options, prefer **broad unit coverage** (see [Tests / Unit tests in validate](#4-tests-mandatory)).
- **Examples**: `validate/example_test.go` with `ExampleMyFormat` and `// Output:`.

If the `it` constraint needs options, define option types and funcs in `validate` (e.g. `validate.MyOptions`, `validate.AllowXxx()`), and use them from `it` and `is`.
Expand Down
79 changes: 42 additions & 37 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This document provides guidance for AI coding agents working with this Go valida
## Project Overview

This is a comprehensive Go validation library that provides:

- Declarative validation using struct tags
- Chainable validation constraints via `it` package
- Conditional checks via `is` package
Expand All @@ -16,14 +17,14 @@ This is a comprehensive Go validation library that provides:

### Core Components

- **`validation.go`** - Main validation entry points and `Validatable` interface
- **`validator.go`** - Core validation logic and execution
- **`constraints.go`** - Constraint interface and execution
- **`it/`** - Constraint builders for assertions (e.g., `it.IsEmail()`, `it.MinLength()`)
- **`is/`** - Boolean check functions (e.g., `is.Email()`, `is.URL()`)
- **`validate/`** - Standalone validation functions
- **`violations.go`** - Validation error handling
- **`message/`** - Message templating and translation system
- `**validation.go**` - Main validation entry points and `Validatable` interface
- `**validator.go**` - Core validation logic and execution
- `**constraints.go**` - Constraint interface and execution
- `**it/**` - Constraint builders for assertions (e.g., `it.IsEmail()`, `it.MinLength()`)
- `**is/**` - Boolean check functions (e.g., `is.Email()`, `is.URL()`)
- `**validate/**` - Standalone validation functions
- `**violations.go**` - Validation error handling
- `**message/**` - Message templating and translation system

### Validation Flow

Expand All @@ -39,19 +40,16 @@ This is a comprehensive Go validation library that provides:
When adding new validation constraints:

1. **Add boolean check to `is/` package** (if needed)
- Pure functions returning `bool`
- No error handling, just true/false

- Pure functions returning `bool`
- No error handling, just true/false
2. **Add constraint builder to `it/` package**
- Returns a `validation.Constraint`
- Uses corresponding `is/` function
- Defines violation message template

- Returns a `validation.Constraint`
- Uses corresponding `is/` function
- Defines violation message template
3. **Add tests in `test/constraints_*_cases_test.go`**
- Use table-driven tests
- Test both valid and invalid cases
- Include edge cases

- Use table-driven tests
- Test both valid and invalid cases
- Include edge cases
4. **Add examples** in relevant `example_*_test.go` files

### Writing Tests
Expand All @@ -77,6 +75,13 @@ func TestConstraintName(t *testing.T) {
}
```

**Black-box tests next to package code** (`is/`, `it/`, `validate/`, root `validation` package, etc.):

- Prefer **black-box testing**: put tests in `*_test.go` files that declare `**package foo_test`** (the package name must end with the `_test` suffix), and import `**github.com/muonsoft/validation/foo**` to exercise only the **exported** API.
- Use `**package foo`** in the same directory only when the test must call **unexported** identifiers (unusual); keep those cases minimal and justified.

Integration-style constraint suites stay in `**test/`** as today (`test/constraints_*_cases_test.go`).

### Message Templates

When defining new constraints, use message templates:
Expand All @@ -89,31 +94,30 @@ validation.NewError(
```

Add translations in:

- `message/translations/english/messages.go`
- `message/translations/russian/messages.go`

## Code Style Guidelines

1. **Naming Conventions**
- Use clear, descriptive names
- Constraint builders: `it.IsXxx()`, `it.HasXxx()`, `it.Xxx()`
- Check functions: `is.Xxx()`

- Use clear, descriptive names
- Constraint builders: `it.IsXxx()`, `it.HasXxx()`, `it.Xxx()`
- Check functions: `is.Xxx()`
2. **Documentation**
- All exported functions must have godoc comments
- Include examples in `example_*_test.go` files
- Use `// Output:` comments for testable examples

- All exported functions must have godoc comments
- Include examples in `example_*_test.go` files
- Use `// Output:` comments for testable examples
3. **Error Handling**
- Use `violations.go` types for validation errors
- Preserve constraint paths for nested validations
- Provide clear, actionable error messages

- Use `violations.go` types for validation errors
- Preserve constraint paths for nested validations
- Provide clear, actionable error messages
4. **Testing**
- Aim for high test coverage
- Use table-driven tests
- Test edge cases and error conditions
- Include benchmarks for performance-critical code
- Aim for high test coverage
- Use table-driven tests
- Test edge cases and error conditions
- Include benchmarks for performance-critical code
- For unit tests in `is/`, `it/`, `validate/`, etc., use `**package foo_test`** black-box tests (see [Writing Tests](#writing-tests))

## File Organization

Expand All @@ -122,7 +126,7 @@ When adding new functionality:
- **Constraints** → `it/` package
- **Check functions** → `is/` package
- **Standalone validators** → `validate/` package
- **Tests** → `test/` directory
- **Tests** → shared constraint suites in `test/`; package unit tests in `*_test.go` with `package foo_test` where applicable
- **Examples** → `example_*_test.go` in root
- **Messages** → `message/translations/`

Expand Down Expand Up @@ -186,7 +190,7 @@ func (c NumericConstraint) ValidateString(ctx context.Context, validator *valida

## Changelog

The project uses **[Keep a Changelog](https://keepachangelog.com/)** in **`CHANGELOG.md`**.
The project uses **[Keep a Changelog](https://keepachangelog.com/)** in `**CHANGELOG.md`**.

### Rules for agents and contributors

Expand Down Expand Up @@ -221,3 +225,4 @@ This is a pure Go library with no external services or infrastructure dependenci
- The CI workflow (`.github/workflows/tests.yml`) pins `golangci-lint` at **v2.11.4** and Go at **^1.24**. Match these versions locally.
- The `.golangci.yml` uses config **version: "2"** (golangci-lint v2 format). Do not use golangci-lint v1.
- No Makefile, Docker, or docker-compose is used. No services need to be started.

10 changes: 4 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased](https://github.com/muonsoft/validation/compare/v0.19.0...HEAD)

### 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`).
- 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`).
- 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 All @@ -19,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`).
- **HasUniqueValuesBy**: `SkipEmptyKeys()` on `it.UniqueByConstraint` skips elements whose key equals the zero value for `K`, so they are not counted toward uniqueness (e.g. optional IDs).

## [0.19.0] - 2026-02-09
## [0.19.0](https://github.com/muonsoft/validation/releases/tag/v0.19.0) - 2026-02-09

### Added

Expand All @@ -40,7 +41,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Correct handling of single violations returned from validatable objects in `validateIt`.

[Unreleased]: https://github.com/muonsoft/validation/compare/v0.19.0...HEAD
[0.19.0]: https://github.com/muonsoft/validation/releases/tag/v0.19.0
- Correct handling of single violations returned from validatable objects in `validateIt`.
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var (
ErrInvalidIP = NewError("invalid IP address", message.InvalidIP)
ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON)
ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN)
ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC)
ErrInvalidTime = NewError("invalid time", message.InvalidTime)
ErrInvalidULID = NewError("invalid ULID", message.InvalidULID)
ErrInvalidUPCA = NewError("invalid UPC-A", message.InvalidUPCA)
Expand Down
10 changes: 10 additions & 0 deletions is/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ func ExampleCIDR() {
// false
}

func ExampleMACAddress() {
fmt.Println(is.MACAddress("00:1a:2b:3c:4d:5e"))
fmt.Println(is.MACAddress("bad"))
fmt.Println(is.MACAddress("ff:ff:ff:ff:ff:ff", validate.WithMacAddressType(validate.MacAddressTypeBroadcast)))
// Output:
// true
// false
// true
}

func ExampleHostname() {
fmt.Println(is.Hostname("example.com")) // valid
fmt.Println(is.Hostname("example.localhost")) // valid
Expand Down
6 changes: 6 additions & 0 deletions is/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ func CIDR(value string, options ...func(*validate.CIDROptions)) bool {
return validate.CIDR(value, options...) == nil
}

// MACAddress checks that a value is a valid 48-bit MAC address with optional Symfony-compatible
// type filtering. See [github.com/muonsoft/validation/validate.MacAddress] for options.
func MACAddress(value string, options ...func(*validate.MacAddressOptions)) bool {
return validate.MacAddress(value, options...) == nil
}

// Hostname checks that a value is a valid hostname. It checks that each label
// within a valid hostname may be no more than 63 octets long. Also, it checks that
// the total length of the hostname must not exceed 255 characters.
Expand Down
16 changes: 16 additions & 0 deletions it/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,22 @@ func ExampleIsCIDR_invalid() {
// violation: "This value is not a valid CIDR notation."
}

func ExampleIsMacAddress_valid() {
v := "00:1a:2b:3c:4d:5e"
err := validator.Validate(context.Background(), validation.String(v, it.IsMacAddress()))
fmt.Println(err)
// Output:
// <nil>
}

func ExampleIsMacAddress_invalid() {
v := "not-a-mac"
err := validator.Validate(context.Background(), validation.String(v, it.IsMacAddress()))
fmt.Println(err)
// Output:
// violation: "This value is not a valid MAC address."
}

func ExampleIPConstraint_DenyPrivateIP_restrictedPrivateIPv4() {
v := "192.168.1.0"
err := validator.Validate(context.Background(), validation.String(v, it.IsIP().DenyPrivateIP()))
Expand Down
87 changes: 87 additions & 0 deletions it/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,3 +514,90 @@ func (c CIDRConstraint) ValidateString(ctx context.Context, validator *validatio
func (c CIDRConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error {
return c.ValidateString(ctx, validator, &v)
}

// MacAddressConstraint validates a string as a 48-bit MAC address, aligned with
// Symfony\Component\Validator\Constraints\MacAddress.
//
// Accepted input forms are those supported by [net.ParseMAC] for a 6-octet address (colon, hyphen,
// or dot separators). Use [MacAddressConstraint.WithType] to filter by unicast/multicast, local/universal,
// and broadcast, using the same type names as Symfony (see [validate.MacAddressTypeAll] and related constants).
type MacAddressConstraint struct {
isIgnored bool
groups []string
options []func(*validate.MacAddressOptions)
err error
messageTemplate string
messageParameters validation.TemplateParameterList
}

// IsMacAddress creates a [MacAddressConstraint] with default type [validate.MacAddressTypeAll].
func IsMacAddress() MacAddressConstraint {
return MacAddressConstraint{
err: validation.ErrInvalidMAC,
messageTemplate: validation.ErrInvalidMAC.Message(),
}
}

// WithType sets the Symfony-compatible MAC class filter (default [validate.MacAddressTypeAll]).
//
// Allowed values are [validate.MacAddressTypeAll], [validate.MacAddressTypeAllNoBroadcast],
// [validate.MacAddressTypeLocalAll], [validate.MacAddressTypeLocalNoBroadcast],
// [validate.MacAddressTypeLocalUnicast], [validate.MacAddressTypeLocalMulticast],
// [validate.MacAddressTypeLocalMulticastNoBroadcast], [validate.MacAddressTypeUniversalAll],
// [validate.MacAddressTypeUniversalUnicast], [validate.MacAddressTypeUniversalMulticast],
// [validate.MacAddressTypeUnicastAll], [validate.MacAddressTypeMulticastAll],
// [validate.MacAddressTypeMulticastNoBroadcast], and [validate.MacAddressTypeBroadcast].
func (c MacAddressConstraint) WithType(macType validate.MacAddressType) MacAddressConstraint {
c.options = append(c.options, validate.WithMacAddressType(macType))
return c
}

// WithError overrides the default error for produced violations.
func (c MacAddressConstraint) WithError(err error) MacAddressConstraint {
c.err = err
return c
}

// WithMessage sets the violation message template. You can set custom template parameters
// for injecting its values into the final message. Also, you can use default parameters:
//
// {{ value }} - the current (invalid) value.
func (c MacAddressConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) MacAddressConstraint {
c.messageTemplate = template
c.messageParameters = parameters
return c
}

// When enables conditional validation of this constraint. If the expression evaluates to false,
// then the constraint will be ignored.
func (c MacAddressConstraint) When(condition bool) MacAddressConstraint {
c.isIgnored = !condition
return c
}

// WhenGroups enables conditional validation of the constraint by using the validation groups.
func (c MacAddressConstraint) WhenGroups(groups ...string) MacAddressConstraint {
c.groups = groups
return c
}

func (c MacAddressConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error {
if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" {
return nil
}
if validate.MacAddress(*value, c.options...) == nil {
return nil
}
return validator.BuildViolation(ctx, c.err, c.messageTemplate).
WithParameters(
c.messageParameters.Prepend(
validation.TemplateParameter{Key: "{{ value }}", Value: *value},
)...,
).
Create()
}

// Validate implements [validation.Constraint][string] so the constraint can be used with [validation.Each] and [validation.This].
func (c MacAddressConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error {
return c.ValidateString(ctx, validator, &v)
}
1 change: 1 addition & 0 deletions message/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
InvalidIP = "This is not a valid IP address."
InvalidJSON = "This value should be valid JSON."
InvalidLUHN = "Invalid card number."
InvalidMAC = "This value is not a valid MAC address."
InvalidTime = "This value is not a valid time."
InvalidULID = "This is not a valid ULID."
InvalidUPCA = "This value is not a valid UPC-A."
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 @@ -67,6 +67,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidIP: catalog.String(message.InvalidIP),
message.InvalidJSON: catalog.String(message.InvalidJSON),
message.InvalidLUHN: catalog.String(message.InvalidLUHN),
message.InvalidMAC: catalog.String(message.InvalidMAC),
message.InvalidTime: catalog.String(message.InvalidTime),
message.InvalidULID: catalog.String(message.InvalidULID),
message.InvalidUPCA: catalog.String(message.InvalidUPCA),
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 @@ -70,6 +70,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{
message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."),
message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."),
message.InvalidLUHN: catalog.String("Недействительный номер карты."),
message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."),
message.InvalidTime: catalog.String("Значение времени недопустимо."),
message.InvalidULID: catalog.String("Значение не соответствует формату ULID."),
message.InvalidUPCA: catalog.String("Значение не является допустимым UPC-A."),
Expand Down
Loading
Loading