diff --git a/CHANGELOG.md b/CHANGELOG.md index 1108eb2..ab501af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). - **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). +- IANA timezone validation: `it.IsTimezone()` with `WithZone` (`validate.TimezoneZoneAfrica`, `TimezoneZoneEurope`, etc.), `validate.Timezone` with `validate.WithTimezoneZone`, `is.Timezone`; `validation.ErrInvalidTimezone` / `message.InvalidTimezone` and English and Russian translations (behavior aligned with Symfony `Timezone`; uses [time.LoadLocation]). ## [0.19.0](https://github.com/muonsoft/validation/releases/tag/v0.19.0) - 2026-02-09 diff --git a/errors.go b/errors.go index 3639618..466ceb7 100644 --- a/errors.go +++ b/errors.go @@ -31,6 +31,7 @@ var ( ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN) ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC) ErrInvalidTime = NewError("invalid time", message.InvalidTime) + ErrInvalidTimezone = NewError("invalid timezone", message.InvalidTimezone) ErrInvalidULID = NewError("invalid ULID", message.InvalidULID) ErrInvalidUPCA = NewError("invalid UPC-A", message.InvalidUPCA) ErrInvalidUPCE = NewError("invalid UPC-E", message.InvalidUPCE) diff --git a/is/example_test.go b/is/example_test.go index 182131a..9c77a90 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -293,6 +293,16 @@ func ExampleCurrency() { // false } +func ExampleTimezone() { + fmt.Println(is.Timezone("Europe/Berlin")) + fmt.Println(is.Timezone("EST")) + fmt.Println(is.Timezone("America/Chicago", validate.WithTimezoneZone(validate.TimezoneZoneAmerica))) + // Output: + // true + // false + // true +} + func ExampleBIC() { fmt.Println(is.BIC("DEUTDEFF")) fmt.Println(is.BIC("DEUTDEF")) diff --git a/is/timezone.go b/is/timezone.go new file mode 100644 index 0000000..fe3c715 --- /dev/null +++ b/is/timezone.go @@ -0,0 +1,9 @@ +package is + +import "github.com/muonsoft/validation/validate" + +// Timezone validates whether the value is a known IANA timezone identifier. +// See [github.com/muonsoft/validation/validate.Timezone] for rules and options. +func Timezone(value string, options ...func(*validate.TimezoneOptions)) bool { + return validate.Timezone(value, options...) == nil +} diff --git a/it/date_time.go b/it/date_time.go index eb0aab7..261e522 100644 --- a/it/date_time.go +++ b/it/date_time.go @@ -5,6 +5,7 @@ import ( "time" "github.com/muonsoft/validation" + "github.com/muonsoft/validation/validate" ) // DateTimeConstraint checks that the string value is a valid date and time value specified by a specific layout. @@ -106,3 +107,86 @@ func (c DateTimeConstraint) ValidateString(ctx context.Context, validator *valid func (c DateTimeConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error { return c.ValidateString(ctx, validator, &v) } + +// TimezoneConstraint validates whether the string value is a known IANA timezone identifier, +// as in Symfony\Component\Validator\Constraints\Timezone. +// Use [TimezoneConstraint.WithZone] to restrict identifiers to a geographical region. +type TimezoneConstraint struct { + isIgnored bool + groups []string + options []func(*validate.TimezoneOptions) + err error + messageTemplate string + messageParameters validation.TemplateParameterList +} + +// IsTimezone validates whether the string value is a known IANA timezone identifier. +// See [TimezoneConstraint] for configuration options. +func IsTimezone() TimezoneConstraint { + return TimezoneConstraint{ + err: validation.ErrInvalidTimezone, + messageTemplate: validation.ErrInvalidTimezone.Message(), + } +} + +// WithZone restricts valid timezone identifiers to the given geographical region +// (default accepts any IANA zone). Allowed values are [validate.TimezoneZoneAll], +// [validate.TimezoneZoneAfrica], [validate.TimezoneZoneAmerica], [validate.TimezoneZoneAntarctica], +// [validate.TimezoneZoneArctic], [validate.TimezoneZoneAsia], [validate.TimezoneZoneAtlantic], +// [validate.TimezoneZoneAustralia], [validate.TimezoneZoneEurope], [validate.TimezoneZoneIndian], +// and [validate.TimezoneZonePacific]. +func (c TimezoneConstraint) WithZone(zone validate.TimezoneZone) TimezoneConstraint { + c.options = append(c.options, validate.WithTimezoneZone(zone)) + return c +} + +// WithError overrides default error for produced violation. +func (c TimezoneConstraint) WithError(err error) TimezoneConstraint { + 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 TimezoneConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) TimezoneConstraint { + 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 TimezoneConstraint) When(condition bool) TimezoneConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups enables conditional validation of the constraint by using the validation groups. +func (c TimezoneConstraint) WhenGroups(groups ...string) TimezoneConstraint { + c.groups = groups + return c +} + +func (c TimezoneConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" { + return nil + } + if validate.Timezone(*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 TimezoneConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error { + return c.ValidateString(ctx, validator, &v) +} diff --git a/it/example_test.go b/it/example_test.go index a9561cc..a95ac83 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -653,6 +653,20 @@ func ExampleIsDate() { // #3 custom layout: } +func ExampleIsTimezone_valid() { + err := validator.Validate(context.Background(), validation.String("Europe/Berlin", it.IsTimezone())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsTimezone_invalid() { + err := validator.Validate(context.Background(), validation.String("EST", it.IsTimezone())) + fmt.Println(err) + // Output: + // violation: "This value is not a valid timezone." +} + func ExampleHasMinCount() { v := []int{1, 2} err := validator.ValidateCountable(context.Background(), len(v), it.HasMinCount(3)) diff --git a/message/messages.go b/message/messages.go index 9e23904..5b4e584 100644 --- a/message/messages.go +++ b/message/messages.go @@ -49,6 +49,7 @@ const ( InvalidLUHN = "Invalid card number." InvalidMAC = "This value is not a valid MAC address." InvalidTime = "This value is not a valid time." + InvalidTimezone = "This value is not a valid timezone." InvalidULID = "This is not a valid ULID." InvalidUPCA = "This value is not a valid UPC-A." InvalidUPCE = "This value is not a valid UPC-E." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 262fde0..c35343f 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -70,6 +70,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidLUHN: catalog.String(message.InvalidLUHN), message.InvalidMAC: catalog.String(message.InvalidMAC), message.InvalidTime: catalog.String(message.InvalidTime), + message.InvalidTimezone: catalog.String(message.InvalidTimezone), message.InvalidULID: catalog.String(message.InvalidULID), message.InvalidUPCA: catalog.String(message.InvalidUPCA), message.InvalidUPCE: catalog.String(message.InvalidUPCE), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 0b27052..bd75580 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -73,6 +73,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidLUHN: catalog.String("Недействительный номер карты."), message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."), message.InvalidTime: catalog.String("Значение времени недопустимо."), + message.InvalidTimezone: catalog.String("Значение не является допустимым часовым поясом."), message.InvalidULID: catalog.String("Значение не соответствует формату ULID."), message.InvalidUPCA: catalog.String("Значение не является допустимым UPC-A."), message.InvalidUPCE: catalog.String("Значение не является допустимым UPC-E."), diff --git a/test/constraints_date_time_test.go b/test/constraints_date_time_test.go index 54842fc..b1423f3 100644 --- a/test/constraints_date_time_test.go +++ b/test/constraints_date_time_test.go @@ -3,6 +3,8 @@ package test import ( "github.com/muonsoft/validation" "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message" + "github.com/muonsoft/validation/validate" ) var dateTimeConstraintTestCases = []ConstraintValidationTestCase{ @@ -112,4 +114,93 @@ var dateTimeConstraintTestCases = []ConstraintValidationTestCase{ constraint: it.IsTime(), assert: assertHasOneViolation(validation.ErrInvalidTime, "This value is not a valid time."), }, + { + name: "IsTimezone passes on empty value", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue(""), + constraint: it.IsTimezone(), + assert: assertNoError, + }, + { + name: "IsTimezone passes on UTC", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("UTC"), + constraint: it.IsTimezone(), + assert: assertNoError, + }, + { + name: "IsTimezone passes on valid IANA identifier", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("Europe/Berlin"), + constraint: it.IsTimezone(), + assert: assertNoError, + }, + { + name: "IsTimezone violation on unknown identifier", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("Invalid/Zone"), + constraint: it.IsTimezone(), + assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone), + }, + { + name: "IsTimezone violation on Local", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("Local"), + constraint: it.IsTimezone(), + assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone), + }, + { + name: "IsTimezone violation on abbreviation", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("EST"), + constraint: it.IsTimezone(), + assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone), + }, + { + name: "IsTimezone passes with matching zone", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("Europe/Paris"), + constraint: it.IsTimezone().WithZone(validate.TimezoneZoneEurope), + assert: assertNoError, + }, + { + name: "IsTimezone violation with non-matching zone", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("America/New_York"), + constraint: it.IsTimezone().WithZone(validate.TimezoneZoneEurope), + assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone), + }, + { + name: "IsTimezone violation with custom error and message", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsTimezone(). + WithError(ErrCustom). + WithMessage( + `Invalid timezone "{{ value }}" for {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}, + ), + stringValue: stringValue("EST"), + assert: assertHasOneViolation(ErrCustom, `Invalid timezone "EST" for parameter.`), + }, + { + name: "IsTimezone passes when condition is false", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsTimezone().When(false), + stringValue: stringValue("EST"), + assert: assertNoError, + }, + { + name: "IsTimezone violation when condition is true", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsTimezone().When(true), + stringValue: stringValue("EST"), + assert: assertHasOneViolation(validation.ErrInvalidTimezone, message.InvalidTimezone), + }, + { + name: "IsTimezone passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsTimezone().WhenGroups(testGroup), + stringValue: stringValue("EST"), + assert: assertNoError, + }, } diff --git a/validate/example_test.go b/validate/example_test.go index e3ad9c5..e486b5a 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -190,6 +190,16 @@ func ExampleCurrency() { // invalid currency } +func ExampleTimezone() { + fmt.Println(validate.Timezone("Europe/Berlin")) + fmt.Println(validate.Timezone("EST")) + fmt.Println(validate.Timezone("America/Chicago", validate.WithTimezoneZone(validate.TimezoneZoneAmerica))) + // Output: + // + // invalid timezone + // +} + func ExampleBIC() { fmt.Println(validate.BIC("DEUTDEFF")) fmt.Println(validate.BIC("DEUTDEF")) diff --git a/validate/timezone.go b/validate/timezone.go new file mode 100644 index 0000000..6c3c67d --- /dev/null +++ b/validate/timezone.go @@ -0,0 +1,101 @@ +package validate + +import ( + "errors" + "strings" + "time" +) + +// ErrInvalidTimezone is returned by [Timezone] when the value is not a valid IANA timezone identifier. +var ErrInvalidTimezone = errors.New("invalid timezone") + +// TimezoneZone restricts valid timezone identifiers to a geographical region, +// aligned with Symfony\Component\Validator\Constraints\Timezone zone option +// (PHP \DateTimeZone::AFRICA, ::AMERICA, and similar constants). +type TimezoneZone string + +const ( + // TimezoneZoneAll accepts any IANA timezone identifier known to [time.LoadLocation], + // excluding implementation-specific names such as "Local". + TimezoneZoneAll TimezoneZone = "" + // TimezoneZoneAfrica restricts identifiers to the Africa region (e.g. "Africa/Nairobi"). + TimezoneZoneAfrica TimezoneZone = "Africa" + // TimezoneZoneAmerica restricts identifiers to the America region (e.g. "America/New_York"). + TimezoneZoneAmerica TimezoneZone = "America" + // TimezoneZoneAntarctica restricts identifiers to the Antarctica region. + TimezoneZoneAntarctica TimezoneZone = "Antarctica" + // TimezoneZoneArctic restricts identifiers to the Arctic region. + TimezoneZoneArctic TimezoneZone = "Arctic" + // TimezoneZoneAsia restricts identifiers to the Asia region (e.g. "Asia/Tokyo"). + TimezoneZoneAsia TimezoneZone = "Asia" + // TimezoneZoneAtlantic restricts identifiers to the Atlantic region. + TimezoneZoneAtlantic TimezoneZone = "Atlantic" + // TimezoneZoneAustralia restricts identifiers to the Australia region. + TimezoneZoneAustralia TimezoneZone = "Australia" + // TimezoneZoneEurope restricts identifiers to the Europe region (e.g. "Europe/Berlin"). + TimezoneZoneEurope TimezoneZone = "Europe" + // TimezoneZoneIndian restricts identifiers to the Indian Ocean region. + TimezoneZoneIndian TimezoneZone = "Indian" + // TimezoneZonePacific restricts identifiers to the Pacific region. + TimezoneZonePacific TimezoneZone = "Pacific" +) + +// TimezoneOptions configures [Timezone] validation. +type TimezoneOptions struct { + zone TimezoneZone +} + +func newTimezoneOptions() TimezoneOptions { + return TimezoneOptions{zone: TimezoneZoneAll} +} + +// WithTimezoneZone sets the geographical region filter (default [TimezoneZoneAll]). +func WithTimezoneZone(zone TimezoneZone) func(*TimezoneOptions) { + return func(o *TimezoneOptions) { + o.zone = zone + } +} + +// Timezone validates whether the value is a known IANA timezone identifier, +// as in Symfony\Component\Validator\Constraints\Timezone. +// +// Validation uses [time.LoadLocation] and requires canonical IANA-style identifiers: +// either exactly "UTC" or a name containing "/" (e.g. "Europe/Berlin", "Etc/GMT+5"). +// Implementation-specific names such as "Local", bare abbreviations (e.g. "EST"), and +// unknown identifiers are rejected. +// +// Empty string is considered valid (use [NotBlank] or similar to reject empty values). +// +// Possible errors: +// - [ErrInvalidTimezone] when the string is unknown, not IANA-shaped, or outside the configured zone. +func Timezone(value string, options ...func(*TimezoneOptions)) error { + if value == "" { + return nil + } + if !isIANATimezoneIdentifier(value) { + return ErrInvalidTimezone + } + + opts := newTimezoneOptions() + for _, opt := range options { + opt(&opts) + } + if opts.zone != TimezoneZoneAll && !strings.HasPrefix(value, string(opts.zone)+"/") { + return ErrInvalidTimezone + } + return nil +} + +func isIANATimezoneIdentifier(value string) bool { + switch value { + case "Local", "Factory": + return false + } + if _, err := time.LoadLocation(value); err != nil { + return false + } + if value == "UTC" { + return true + } + return strings.Contains(value, "/") +} diff --git a/validate/timezone_test.go b/validate/timezone_test.go new file mode 100644 index 0000000..d928be9 --- /dev/null +++ b/validate/timezone_test.go @@ -0,0 +1,64 @@ +package validate_test + +import ( + "errors" + "testing" + + "github.com/muonsoft/validation/validate" +) + +func TestTimezone(t *testing.T) { + tests := []struct { + name string + value string + options []func(*validate.TimezoneOptions) + wantErr error + }{ + {name: "empty", value: ""}, + {name: "UTC", value: "UTC"}, + {name: "Europe/Berlin", value: "Europe/Berlin"}, + {name: "America/New_York", value: "America/New_York"}, + {name: "Etc/GMT+5", value: "Etc/GMT+5"}, + {name: "invalid unknown", value: "Invalid/Zone", wantErr: validate.ErrInvalidTimezone}, + {name: "invalid Local", value: "Local", wantErr: validate.ErrInvalidTimezone}, + {name: "invalid Factory", value: "Factory", wantErr: validate.ErrInvalidTimezone}, + {name: "invalid abbreviation EST", value: "EST", wantErr: validate.ErrInvalidTimezone}, + {name: "invalid abbreviation GMT", value: "GMT", wantErr: validate.ErrInvalidTimezone}, + {name: "invalid no slash", value: "Berlin", wantErr: validate.ErrInvalidTimezone}, + { + name: "zone Europe valid", + value: "Europe/Paris", + options: []func(*validate.TimezoneOptions){validate.WithTimezoneZone(validate.TimezoneZoneEurope)}, + }, + { + name: "zone Europe invalid", + value: "America/New_York", + options: []func(*validate.TimezoneOptions){validate.WithTimezoneZone(validate.TimezoneZoneEurope)}, + wantErr: validate.ErrInvalidTimezone, + }, + { + name: "zone America valid", + value: "America/Chicago", + options: []func(*validate.TimezoneOptions){validate.WithTimezoneZone(validate.TimezoneZoneAmerica)}, + }, + { + name: "zone Asia valid", + value: "Asia/Tokyo", + options: []func(*validate.TimezoneOptions){validate.WithTimezoneZone(validate.TimezoneZoneAsia)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Timezone(tt.value, tt.options...) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("Timezone(%q): %v", tt.value, err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Timezone(%q): got %v, want %v", tt.value, err, tt.wantErr) + } + }) + } +}