diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index dab3084..6afa674 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -9,30 +9,26 @@ jobs: runs-on: ubuntu-latest name: Update coverage badge steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v6 - name: Setup go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '^1.18' - - uses: actions/cache@v4 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version: stable + cache: true - name: Run Test run: | go test -v ./... -covermode=count -coverprofile=coverage.out go tool cover -func=coverage.out -o=coverage.out - - name: Go Coverage Badge # Pass the `coverage.out` output to this action + - name: Go Coverage Badge uses: tj-actions/coverage-badge-go@v3 with: filename: coverage.out - name: Verify Changed files - uses: tj-actions/verify-changed-files@v18 + uses: tj-actions/verify-changed-files@v20 id: verify-changed-files with: files: README.md @@ -47,7 +43,7 @@ jobs: - name: Push changes if: steps.verify-changed-files.outputs.files_changed == 'true' - uses: ad-m/github-push-action@v0.8.0 + uses: ad-m/github-push-action@v1 with: github_token: ${{ github.token }} branch: ${{ github.head_ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62bf57a..a658326 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: name: Tagged Release runs-on: ubuntu-latest steps: - - uses: "marvinpinto/action-automatic-releases@latest" + - uses: actions/checkout@v6 + - uses: softprops/action-gh-release@v3 with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - prerelease: false + generate_release_notes: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b320b41..3c3f8b0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,29 +8,31 @@ on: jobs: lint: - name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup go + uses: actions/setup-go@v6 with: - go-version: '^1.24' - - name: golangci-lint + go-version: stable + - name: Setup golangci-lint uses: golangci/golangci-lint-action@v8 with: version: latest + args: --verbose test: needs: lint strategy: matrix: - go: ["1.18", "1.19", "1.20", "1.21", "1.22", "1.23", "1.24", "1.25", "1.26"] + go: ["1.21", "1.22", "1.23", "1.24", "1.25", "1.26"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout Code + uses: actions/checkout@v6 - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - name: Run Tests - run: | - go test -v ./... + run: go test -v ./... diff --git a/Makefile b/Makefile index 67481db..15fa4dd 100644 --- a/Makefile +++ b/Makefile @@ -1,33 +1,46 @@ -# Makefile for go-validator - -SHELL := /bin/bash -GO ?= go -PKG ?= ./... -TEST_FLAGS ?= -race -count=1 -COVER_PROFILE ?= coverage.out -BIN ?= go-validator -GOLANGCI_LINT ?= golangci-lint -LINT_VERSION ?= v1.61.0 - -.PHONY: help lint tidy test coverage bench clean - -help: ## Show this help - @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9_.-]+:.*##/ {printf "\033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) - -test: ## Run unit tests - $(GO) test $(TEST_FLAGS) $(PKG) - -coverage: ## Run tests with coverage and generate reports - $(GO) test -race -covermode=atomic -coverprofile $(COVER_PROFILE) $(PKG) - $(GO) tool cover -func=$(COVER_PROFILE) | tail -n 1 - @echo "HTML report: $(COVER_PROFILE).html" - $(GO) tool cover -html=$(COVER_PROFILE) -o $(COVER_PROFILE).html - -bench: ## Run benchmarks - $(GO) test -bench=. -benchmem $(PKG) - -lint: ## Run golangci-lint - $(GOLANGCI_LINT) run ./... - -clean: ## Clean generated artifacts - rm -f $(COVER_PROFILE) $(COVER_PROFILE).html +# Colors for output +GREEN := \033[32m +YELLOW := \033[33m +CYAN := \033[36m +RESET := \033[0m + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help message + @echo "$(GREEN)Go Validator Makefile$(RESET)" + @echo "Usage: make $(CYAN)$(RESET)" + @awk 'BEGIN {FS = ":.*##";} \ + /^[a-zA-Z0-9_-]+:.*?##/ { printf " $(CYAN)%-15s$(RESET) %s\n", $$1, $$2 } \ + /^##@/ { printf "\n$(YELLOW)%s$(RESET)\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +.PHONY: test test-coverage clean fmt lint tidy + +test: ## Run tests + @echo "$(GREEN)Running tests (with race)...$(RESET)" + @go test -v -race ./... + +test-coverage: ## Run tests with coverage + @echo "$(GREEN)Running tests with coverage...$(RESET)" + @go test -coverprofile=coverage.out ./... + @echo "$(GREEN)Coverage file created: coverage.out$(RESET)" + +clean: ## Clean coverage files + @echo "$(YELLOW)Cleaning coverage files...$(RESET)" + @rm -f coverage.out + @echo "$(GREEN)Clean complete$(RESET)" + +fmt: ## Format code using golangci-lint formatter + @echo "$(GREEN)Formatting code with golangci-lint...$(RESET)" + @golangci-lint fmt + @echo "$(GREEN)Code formatting complete$(RESET)" + +lint: ## Lint code using golangci-lint linter + @echo "$(GREEN)Linting code with golangci-lint...$(RESET)" + @golangci-lint run + @echo "$(GREEN)Linting complete$(RESET)" + +tidy: ## Run go mod tidy + @echo "$(GREEN)Running go mod tidy...$(RESET)" + @go mod tidy + @echo "$(GREEN)Go modules tidied$(RESET)" diff --git a/README.md b/README.md index dc7799e..52fc5b3 100644 --- a/README.md +++ b/README.md @@ -1,437 +1,246 @@ -# Go Validator -![Coverage](https://img.shields.io/badge/Coverage-98.0%25-brightgreen) -[![Go Report Card](https://goreportcard.com/badge/github.com/behzadsh/go.validator)](https://goreportcard.com/report/github.com/behzadsh/go.validator) +# go.validator -The `go.validator` package provides a simple and convenient way to validate your data. +A small, schema-based validator for Go. No globals, no struct tags, no reflection magic in your business code — just a `Schema` you build with chained `Field` calls and validate against any map or struct. -## Installation +```go +schema := validation.New(). + Field("name", validation.Required, validation.MinLength(2)). + Field("email", validation.Required, validation.Email) + +res, err := schema.Validate(input) +if err != nil { + log.Fatal(err) // RuleSyntaxError: misconfigured rule, fix at startup +} +if res.HasErrors() { + for _, e := range res.Errors() { + fmt.Println(e.Path, e.Message) + } +} +``` -To install the `go.validator` package, run the following command: +## Install ```bash -go get -u github.com/behzadsh/go.validator +go get github.com/behzadsh/go.validator/v2 ``` -## How to use -You can use this package to validate HTTP request data in any form. +Requires Go 1.21 or later. -### Validating map data +## Concepts -To validate map data, use the `ValidateMap` function. `ValidateMap` accepts two parameters: the input data -and the validation rules map. +Three types are all you need to know: -```go -package main - -import ( - "encoding/json" - "log" - "net/http" - - validation "github.com/behzadsh/go.validator" -) - -func main() { - http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&body) - if err != nil { - log.Fatal(err) - } - - res := validation.ValidateMap(body, validation.RulesMap{ - "email": {"required", "email:mx"}, - "password": {"required", "minLength:6"}, - "birthDate": {"dateTime"}, - }) - - if res.Failed() { - _ = json.NewEncoder(w).Encode(map[string]any{ - "message": "validation failed", - "errors": res.Errors.All(), // it will return a map[string][]string, key is the field name and the slice is the list of the field errors. - }) - } - }) +- **`Rule`** — an interface with one method: `Validate(value any) error`. Rules are values you pass around. Built-in rules are exposed as variables (e.g. `Required`, `Email`) or constructors that return a `Rule` (e.g. `Min(18)`, `MinLength(3)`). +- **`Schema`** — a sequence of (path, rules) pairs. Build it with `New()` and chain `Field` calls. +- **`Result`** — the value returned by `Schema.Validate`. Inspect it via `HasErrors()`, `Errors()`, and `For(path)`. `Result` does **not** implement the `error` interface; iterate `res.Errors()` to handle individual failures. Each entry is a `FieldError{Path, Err, Message, Code, Params}` and *does* implement `error`. - err := http.ListenAndServe(":8080", nil) - if err != nil { - log.Fatal(err) - } -} +### Absence model +Only the `Required*` and `NotEmpty` rules fail when a field is absent or empty. Every other built-in rule returns `nil` for a missing value. Combine `Required` with another rule to enforce both presence and shape: +```go +.Field("email", validation.Required, validation.Email) ``` -You can also validate other types of data with the following functions: -* `ValidateMapSlice`: Validate a slice of maps (rules are applied to each map). -* `ValidateStruct`: Validate a struct value, just like map validation. -* `ValidateStructSlice`: Same as `ValidateMapSlice`, but for a slice of structs. +Without `Required`, a missing `email` is accepted; if present, it must be a valid email. -### Validating a single variable +## Validating a map ```go -package main - -import ( - "fmt" - "log" - "os" +input := map[string]any{ + "name": "Alice", + "email": "alice@example.com", + "age": 29, +} - validation "github.com/behzadsh/go.validator" -) +schema := validation.New(). + Field("name", validation.Required, validation.MinLength(2)). + Field("email", validation.Required, validation.Email). + Field("age", validation.Min[int](18), validation.Max[int](120)) -func main() { - args := os.Args[1:] +res, _ := schema.Validate(input) +``` - if len(args) == 0 { - log.Fatal("an argument is required.") - } +Nested keys use dot-notation: - res := validation.Validate(args[0], []string{"notEmpty", "integer", "between:3,5"}) +```go +schema := validation.New(). + Field("profile.handle", validation.Required, validation.AlphaNum) - if res.Failed() { - fmt.Println("Validation failed!") - // the validation errors are stored in res.Errors under the `variable` key when using `validation.Validate()`. - for _, value := range res.Errors["variable"] { - fmt.Println(value) - } - os.Exit(1) - } +input := map[string]any{ + "profile": map[string]any{"handle": "alice99"}, } ``` -## Translation and Localization +## Validating a struct -The `go.validator` package supports internationalization (i18n) for validation error messages. You can customize translations and locales in several ways. +The same schema works against a struct or `*struct`. Field names in the path resolve in this order: -### Setting Default Locale +1. The first comma-segment of the `json` struct tag, if present and not `"-"`. +2. The exported Go field name. -You can set a default locale for all validation operations: +A `json:"-"` tag hides the field from the validator. Embedded (anonymous) struct fields are searched recursively. ```go -package main - -import ( - "fmt" - "log" - "net/http" - "encoding/json" - - validation "github.com/behzadsh/go.validator" -) - -func main() { - // Set default locale to Spanish - validation.SetDefaultLocale("es") - - http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&body) - if err != nil { - log.Fatal(err) - } - - // This will use Spanish locale by default - res := validation.ValidateMap(body, validation.RulesMap{ - "email": {"required", "email"}, - "password": {"required", "minLength:6"}, - }) - - if res.Failed() { - _ = json.NewEncoder(w).Encode(map[string]any{ - "message": "validation failed", - "errors": res.Errors.All(), - }) - } - }) - - err := http.ListenAndServe(":8080", nil) - if err != nil { - log.Fatal(err) - } +type User struct { + Name string `json:"name"` + Email string `json:"email"` + Age int `json:"age"` + Profile Profile `json:"profile"` } -``` -### Per-Validation Locale - -You can also specify a locale for individual validation calls: - -```go -// Validate with French locale -res := validation.ValidateMap(data, rules, "fr") +type Profile struct { + Handle string `json:"handle"` +} -// Validate struct with German locale -res := validation.ValidateStruct(user, rules, "de") +schema := validation.New(). + Field("name", validation.Required, validation.MinLength(2)). + Field("email", validation.Required, validation.Email). + Field("age", validation.Min[int](18)). + Field("profile.handle", validation.Required) -// Validate single variable with Italian locale -res := validation.Validate(value, []string{"required", "email"}, "it") +res, _ := schema.Validate(User{Name: "Alice", Email: "alice@example.com", Age: 29}) ``` -### Custom Translation Function - -You can provide your own translation function to handle custom translation logic: +A field without a `json` tag is reachable by its Go name: ```go -package main - -import ( - "fmt" - "strings" - - validation "github.com/behzadsh/go.validator" - "github.com/behzadsh/go.validator/translation" -) - -func main() { - // Set custom translation function - translation.SetDefaultTranslatorFunc(func(locale, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - // Custom translation logic - switch key { - case "validation.required": - switch locale { - case "es": - return "El campo :field: es obligatorio." - case "fr": - return "Le champ :field: est requis." - default: - return "The :field: field is required." - } - case "validation.email": - switch locale { - case "es": - return "El campo :field: debe ser una dirección de correo válida." - case "fr": - return "Le champ :field: doit être une adresse e-mail valide." - default: - return "The :field: field must be a valid email address." - } - default: - return key - } - }) - - // Set default locale - validation.SetDefaultLocale("es") - - // Now all validations will use Spanish translations - res := validation.ValidateMap(map[string]any{ - "email": "", - }, validation.RulesMap{ - "email": {"required", "email"}, - }) - - if res.Failed() { - fmt.Println(res.Errors.All()) - // Output: map[email:[El campo email es obligatorio.]] - } +type Item struct { + SKU string } + +validation.New().Field("SKU", validation.Required).Validate(Item{}) ``` -> For an even easier translation experience, we recommend using the [`go.localization`](https://github.com/behzadsh/go.localization) package. +A `nil` `*struct` is treated as if every field were absent. Required fields will fail; other rules will pass. -### Translation Key Format +## Built-in rules -Validation error messages use translation keys in the format `validation.{ruleName}`. The translation function receives: +See **[RULES.md](RULES.md)** for the complete rule reference with signatures, fail conditions, and examples. -- `locale`: The current locale (e.g., "en", "es", "fr") -- `key`: The translation key (e.g., "validation.required", "validation.email") -- `params`: Optional parameters map containing field names and other placeholders +Quick overview by category: -Common placeholders in translation messages: -- `:field:`: The field name being validated -- `:value:`: The actual value being validated -- `:min:`: Minimum value (for rules like `min`, `minLength`) -- `:max:`: Maximum value (for rules like `max`, `maxLength`) +| Category | Rules | +|---|---| +| General | `Required`, `RequiredIf`, `RequiredUnless`, `RequiredWith`, `RequiredWithAll`, `RequiredWithout`, `RequiredWithoutAll`, `NotEmpty` | +| String | `Alpha`, `AlphaDash`, `AlphaNum`, `AlphaSpace`, `ASCII`, `Base64`, `Contains`, `CreditCard`, `Email`, `EmailMX`, `EndsWith`, `HexColor`, `JSON`, `JWT`, `Length`, `Lowercase`, `MaxLength`, `MinLength`, `NotRegex`, `PhoneE164`, `Regex`, `Semver`, `Slug`, `StartsWith`, `Uppercase`, `URL`, `UUID` | +| Number | `Numeric`, `Integer`, `Min`, `Max`, `GT`, `GTE`, `LT`, `LTE`, `Between`, `Positive`, `Negative`, `NonNegative`, `MultipleOf`, `Port`, `Latitude`, `Longitude` | +| Digit | `Digits`, `MinDigits`, `MaxDigits`, `DigitsBetween` | +| DateTime | `DateTime`, `DateTimeFormat`, `After`, `AfterOrEqual`, `AfterField`, `Before`, `BeforeOrEqual`, `BeforeField`, `DateTimeBetween`, `Timezone` | +| Network | `IP`, `IPv4`, `IPv6`, `CIDR`, `MACAddress` | +| Collection | `Distinct`, `Each`, `Size`, `MinSize`, `MaxSize` | +| Generic | `In`, `NotIn`, `NEQ` | +| Comparison | `SameAs`, `Different` | +| Logical | `Any`, `Not`, `When`, `Unless` | -## Custom Rules +Every rule except `Required`, `RequiredIf`, `RequiredUnless`, `RequiredWith*`, and `NotEmpty` returns `nil` for a missing value. -You can define your own validation rules and register them with the validator. +## RequiredIf -### Rule building blocks +`RequiredIf` accepts a small expression language for cross-field conditions: -- **Rule interface**: implement `Validate(selector string, value any, inputBag bag.InputBag) rules.ValidationResult`. -- **RuleWithParams (optional)**: implement `AddParams(params []string)` and `MinRequiredParams() int` to receive rule parameters (e.g., `between:3,5`). -- **TranslatableRule (optional but recommended)**: embed `translation.BaseTranslatableRule` to get the current locale and a translation function injected automatically. +- **Comparisons:** `field == value`, `field != value`, `field < value`, `field > value`, `field <= value`, `field >= value` +- **Logical:** `expr && expr`, `expr || expr`, `!expr` +- **Grouping:** `(expr)` +- **Functions:** `exists(path)`, `len(path) == n` -### Minimal rule (no params, no translations) +String literals must be quoted (`"admin"` or `'admin'`). Unquoted identifiers are looked up as field paths. ```go -package rules +schema := validation.New(). + Field("billing_address", validation.RequiredIf(`plan == "paid"`)). + Field("company_name", validation.RequiredIf(`role == "business" && exists(vat_number)`)). + Field("note", validation.RequiredIf(`(status == "active" || status == "pending") && verified == true`)) +``` -import ( - "strings" - "github.com/behzadsh/go.validator/bag" -) +## Custom rules -// Palindrome checks if the value is a palindrome string. -type Palindrome struct{} +Any value that implements `Rule` is acceptable. The fastest path is `RuleFunc`: -func (r *Palindrome) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s, ok := value.(string) - if !ok { - return NewFailedResult(":field: must be a string") +```go +isCorporate := validation.RuleFunc(func(v any) error { + if v == nil { + return nil } - t := strings.ToLower(strings.ReplaceAll(s, " ", "")) - i, j := 0, len(t)-1 - for i < j { - if t[i] != t[j] { - return NewFailedResult(":field: must be a palindrome") - } - i++; j-- + s, ok := v.(string) + if !ok || !strings.HasSuffix(s, "@acme.corp") { + return errors.New("must be a corporate email") } - return NewSuccessResult() -} + return nil +}) + +schema := validation.New(). + Field("email", validation.Required, validation.Email, isCorporate) ``` -### Rule with parameters +For rules that need to read other fields, implement `InputRule` or use `InputRuleFunc`: ```go -package rules - -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/spf13/cast" -) - -// MinWords:value — ensures a string has at least :value words. -type MinWords struct { - min int -} - -func (r *MinWords) AddParams(params []string) { - r.min = cast.ToInt(params[0]) +mustMatchField := func(otherPath string) validation.InputRule { + return validation.InputRuleFunc(func(value any, input *validation.InputBag) error { + other, _ := input.Lookup(otherPath) + if value != other { + return errors.New("values do not match") + } + return nil + }) } -func (*MinWords) MinRequiredParams() int { return 1 } - -func (r *MinWords) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s, ok := value.(string) - if !ok { - return NewFailedResult(":field: must be a string") - } - words := 0 - inWord := false - for _, ch := range s { - if ch == ' ' || ch == '\n' || ch == '\t' { inWord = false; continue } - if !inWord { words++; inWord = true } - } - if words < r.min { - return NewFailedResult(":field: must have at least :min: words") - } - return NewSuccessResult() -} +schema := validation.New(). + Field("password_confirm", validation.Required, mustMatchField("password")) ``` -### Adding translations - -Embed `translation.BaseTranslatableRule` and use its `Translate` function. The validator injects the current locale and translator before calling `Validate`. +The built-in `SameAs` and `Different` rules cover the most common cross-field comparison patterns. -```go -package rules +Rules are values: build them once at startup and reuse across validations and goroutines. -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) +## Error handling -// MaxWords:value — ensures a string has at most :value words. -type MaxWords struct { - translation.BaseTranslatableRule - max int +```go +res, err := schema.Validate(input) +if err != nil { + log.Fatal(err) // RuleSyntaxError: misconfigured rule, fix at startup } -func (r *MaxWords) AddParams(params []string) { r.max = cast.ToInt(params[0]) } -func (*MaxWords) MinRequiredParams() int { return 1 } - -func (r *MaxWords) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s, ok := value.(string) - if !ok { - return NewFailedResult(r.Translate(r.Locale, "validation.string", map[string]string{"field": selector})) +if res.HasErrors() { + for _, e := range res.Errors() { + log.Printf("%s: %s", e.Path, e.Message) } - // ... count words into n ... - n := len(strings.Fields(s)) - if n > r.max { - return NewFailedResult(r.Translate(r.Locale, "validation.maxWords", map[string]string{ - "field": selector, - "max": cast.ToString(r.max), - })) - } - return NewSuccessResult() } -``` -You can provide translations by setting a custom translator (see "Translation and Localization" above) and handling keys like `validation.maxWords`. +// Get every error for a single path +for _, e := range res.For("email") { + log.Println(e.Message) +} +``` -### Registering and using your rule +`FieldError` carries `Code` (a stable snake_case key for i18n) and `Params` (rule parameters). Use `Code` to branch on specific failures: ```go -package main - -import ( - validation "github.com/behzadsh/go.validator" - "github.com/behzadsh/go.validator/rules" -) - -func init() { - // Register custom rules once (e.g., app startup). Re-registering a name overrides the previous rule. - validation.Register("palindrome", &rules.Palindrome{}) - validation.Register("minWords", &rules.MinWords{}) -} - -func main() { - data := map[string]any{"title": "able was I ere I saw Elba"} - res := validation.ValidateMap(data, validation.RulesMap{ - "title": {"required", "palindrome", "minWords:2"}, - }) - if res.Failed() { /* handle errors */ } +for _, e := range res.Errors() { + switch e.Code { + case "email": + // handle invalid email + case "min_length": + // e.Params["length"] holds the minimum + } } ``` -### Notes +`FieldError` also implements `error` and exposes the underlying rule error via `Err`, so `errors.As` works for custom rules that return structured errors. -- If you declare `MinRequiredParams() > 0` and the user provides fewer parameters, validation will panic with a clear error. -- If a rule name is not registered, validation will panic. Register custom rules before use. -- Embedding `translation.BaseTranslatableRule` is optional but recommended for localized messages. +`Result` deliberately does **not** implement `error`. Decide at the API boundary how to surface the collection — most apps return the slice from `Errors()` as JSON to the client, or fail-fast on `HasErrors()`. -## Validation Options +## Concurrency -### Stop on first failure +A `Schema` is meant to be built once and called many times. Once construction is complete, `Validate` is safe to call from multiple goroutines: it only reads the schema, and rules are immutable values. -By default, the validator evaluates all rules for each field and accumulates all errors. You can change this behavior to stop evaluating further rules for a field after the first failure. - -```go -package main - -import ( - validation "github.com/behzadsh/go.validator" -) - -func init() { - // Enable once at app startup - validation.StopOnFirstFailure() -} - -func main() { - data := map[string]any{"age": "abc"} - // Only the first failing rule for each field is reported - res := validation.ValidateMap(data, validation.RulesMap{ - "age": {"required", "integer", "between:18,65"}, - }) - _ = res -} -``` +## What this version does not include -Notes: -- This option stops rule evaluation per-field only; other fields are still validated. -- The default is disabled. Calling `StopOnFirstFailure()` enables it globally for subsequent validations. +- An `And` combinator (unnecessary — multiple rules on a `Field` call are implicitly AND). +- Slice or wildcard paths (`items.*.name`). +- Internationalization. Error messages are plain English strings; use `Code` and `Params` on `FieldError` to build translated messages. -## Available Rules +## License -The complete list of available rules can be found [here](https://github.com/behzadsh/go.validator/tree/main/rules.md). +See `LICENSE`. diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..5874457 --- /dev/null +++ b/RULES.md @@ -0,0 +1,1517 @@ +# Available Rules + +## Index + +
+General + +- [Required](#required) +- [RequiredIf](#requiredif) +- [RequiredUnless](#requiredunless) +- [RequiredWith](#requiredwith) +- [RequiredWithAll](#requiredwithall) +- [RequiredWithout](#requiredwithout) +- [RequiredWithoutAll](#requiredwithoutall) +- [NotEmpty](#notempty) + +
+ +
+String + +- [Alpha](#alpha) +- [AlphaDash](#alphadash) +- [AlphaNum](#alphanum) +- [AlphaSpace](#alphaspace) +- [ASCII](#ascii) +- [Base64](#base64) +- [Contains](#contains) +- [CreditCard](#creditcard) +- [Email](#email) +- [EmailMX](#emailmx) +- [EndsWith](#endswith) +- [HexColor](#hexcolor) +- [JSON](#json) +- [JWT](#jwt) +- [Length](#length) +- [Lowercase](#lowercase) +- [MaxLength](#maxlength) +- [MinLength](#minlength) +- [NotRegex](#notregex) +- [PhoneE164](#phonee164) +- [Regex](#regex) +- [Semver](#semver) +- [Slug](#slug) +- [StartsWith](#startswith) +- [Uppercase](#uppercase) +- [UUID](#uuid) + +
+ +
+Number + +- [Between](#between) +- [GT](#gt) +- [GTE](#gte) +- [Integer](#integer) +- [Latitude](#latitude) +- [Longitude](#longitude) +- [LT](#lt) +- [LTE](#lte) +- [Max](#max) +- [Min](#min) +- [MultipleOf](#multipleof) +- [Negative](#negative) +- [NonNegative](#nonnegative) +- [Numeric](#numeric) +- [Port](#port) +- [Positive](#positive) + +
+ +
+Digit + +- [Digits](#digits) +- [DigitsBetween](#digitsbetween) +- [MaxDigits](#maxdigits) +- [MinDigits](#mindigits) + +
+ +
+DateTime + +- [After](#after) +- [AfterField](#afterfield) +- [AfterOrEqual](#afterorequal) +- [Before](#before) +- [BeforeField](#beforefield) +- [BeforeOrEqual](#beforeorequal) +- [DateTime](#datetime) +- [DateTimeBetween](#datetimebetween) +- [DateTimeFormat](#datetimeformat) +- [Timezone](#timezone) + +
+ +
+Network + +- [CIDR](#cidr) +- [IP](#ip) +- [IPv4](#ipv4) +- [IPv6](#ipv6) +- [MACAddress](#macaddress) +- [URL](#url) + +
+ +
+Collection + +- [Distinct](#distinct) +- [Each](#each) +- [MaxSize](#maxsize) +- [MinSize](#minsize) +- [Size](#size) + +
+ +
+Generic + +- [In](#in) +- [NEQ](#neq) +- [NotIn](#notin) + +
+ +
+Comparison + +- [Different](#different) +- [SameAs](#sameas) + +
+ +
+Logical + +- [Any](#any) +- [Not](#not) +- [Unless](#unless) +- [When](#when) + +
+ +--- + +## General + + +### Required + +```go +var Required Rule +``` + +Fails if the value is `nil` or an empty string `""`. Passes for all other types including zero values (`0`, `false`). + +```go +validation.New(). + Field("name", validation.Required) +``` + +--- + + +### RequiredIf + +```go +func RequiredIf(condition string) InputRule +``` + +Fails if the condition evaluates to `true` and the value is `nil` or `""`. + +The condition language supports comparisons (`==`, `!=`, `<`, `>`, `<=`, `>=`), logical operators (`&&`, `||`, `!`), grouping `(...)`, and functions `exists(path)` and `len(path)`. + +```go +validation.New(). + Field("vat_number", validation.RequiredIf(`plan == "paid"`)). + Field("admin_code", validation.RequiredIf(`role == "admin" && exists(org_id)`)) +``` + +--- + + +### RequiredUnless + +```go +func RequiredUnless(condition string) InputRule +``` + +The logical complement of `RequiredIf`. Fails if the condition evaluates to `false` and the value is `nil` or `""`. + +```go +validation.New(). + Field("reason", validation.RequiredUnless(`status == "approved"`)) +``` + +--- + + +### RequiredWith + +```go +func RequiredWith(fields ...string) InputRule +``` + +Fails if **any** of the listed fields are present in the input and the value is `nil` or `""`. + +```go +validation.New(). + Field("address", validation.RequiredWith("phone", "mobile")) +``` + +--- + + +### RequiredWithAll + +```go +func RequiredWithAll(fields ...string) InputRule +``` + +Fails if **all** of the listed fields are present in the input and the value is `nil` or `""`. + +```go +validation.New(). + Field("full_name", validation.RequiredWithAll("first_name", "last_name")) +``` + +--- + + +### RequiredWithout + +```go +func RequiredWithout(fields ...string) InputRule +``` + +Fails if **any** of the listed fields are absent from the input and the value is `nil` or `""`. + +```go +validation.New(). + Field("username", validation.RequiredWithout("email")) +``` + +--- + + +### RequiredWithoutAll + +```go +func RequiredWithoutAll(fields ...string) InputRule +``` + +Fails if **all** of the listed fields are absent from the input and the value is `nil` or `""`. + +```go +validation.New(). + Field("contact", validation.RequiredWithoutAll("email", "phone")) +``` + +--- + + +### NotEmpty + +```go +var NotEmpty Rule +``` + +Fails if the value is `nil`, `""`, `0` (any numeric type), `false`, or a zero-value struct. + +```go +validation.New(). + Field("count", validation.NotEmpty). // rejects 0 + Field("active", validation.NotEmpty) // rejects false +``` + +--- + +## String + + +### ASCII + +```go +var ASCII Rule +``` + +Fails if the value is not a string or contains any byte with value > 127. + +```go +validation.New().Field("code", validation.ASCII) +// "hello" → pass, "café" → fail +``` + +--- + + +### Base64 + +```go +var Base64 Rule +``` + +Fails if the value is not a string or is not valid standard base64 (RFC 4648, with `=` padding). URL-safe base64 (`-_`) is not accepted. Empty string passes. + +```go +validation.New().Field("data", validation.Base64) +// "aGVsbG8=" → pass, "aGVsbG8" → fail (missing padding) +``` + +--- + + +### Contains + +```go +func Contains(sub string) Rule +``` + +Fails if the value is not a string or does not contain `sub`. + +```go +validation.New().Field("bio", validation.Contains("@")) +// "hello@world" → pass, "hello" → fail +``` + +--- + + +### CreditCard + +```go +var CreditCard Rule +``` + +Validates a credit card number using the Luhn algorithm. Spaces and dashes are stripped before validation. Accepted length after stripping: 13–19 digits. + +```go +validation.New().Field("card", validation.CreditCard) +// "4111111111111111" → pass, "4111-1111-1111-1111" → pass +``` + +--- + + +### HexColor + +```go +var HexColor Rule +``` + +Fails if the value is not a string or does not match `#RGB` or `#RRGGBB` (case-insensitive). + +```go +validation.New().Field("color", validation.HexColor) +// "#fff" → pass, "#FF5733" → pass, "FF5733" → fail (missing #) +``` + +--- + + +### JSON + +```go +var JSON Rule +``` + +Fails if the value is not a string or is not valid JSON. Accepts any JSON value (object, array, string, number, boolean, null). Empty string fails. + +```go +validation.New().Field("payload", validation.JSON) +// `{"key":"val"}` → pass, `null` → pass, `{bad}` → fail +``` + +--- + + +### JWT + +```go +var JWT Rule +``` + +Fails if the value is not a string or does not match the JWT compact serialization format (`header.payload.signature` — three base64url segments separated by dots, no padding). + +```go +validation.New().Field("token", validation.JWT) +// "eyJ.eyJ.sig" → pass, "notajwt" → fail +``` + +--- + + +### PhoneE164 + +```go +var PhoneE164 Rule +``` + +Fails if the value is not a string or does not match E.164 format: `+` followed by a non-zero country code digit and 1–14 more digits (2–15 total digits). + +```go +validation.New().Field("phone", validation.PhoneE164) +// "+14155552671" → pass, "14155552671" → fail (missing +) +``` + +--- + + +### Semver + +```go +var Semver Rule +``` + +Fails if the value is not a string or is not a valid semantic version per semver.org. Supports pre-release (`-alpha.1`) and build metadata (`+001`). Leading zeros in numeric identifiers are rejected. + +```go +validation.New().Field("version", validation.Semver) +// "1.0.0" → pass, "2.1.3-alpha.1" → pass, "01.0.0" → fail +``` + +--- + + +### Slug + +```go +var Slug Rule +``` + +Fails if the value is not a string or contains anything other than lowercase ASCII letters, digits, and single hyphens (not at start or end, not consecutive). + +```go +validation.New().Field("handle", validation.Slug) +// "hello-world" → pass, "-leading" → fail, "double--dash" → fail +``` + +--- + + +### Alpha + +```go +var Alpha Rule +``` + +Fails if the value is not a string or contains non-letter characters. Unicode letters are accepted. + +```go +validation.New().Field("name", validation.Alpha) +// "hello" → pass, "Ünïcödé" → pass, "hello1" → fail +``` + +--- + + +### AlphaDash + +```go +var AlphaDash Rule +``` + +Accepts Unicode letters, digits, underscores `_`, and hyphens `-`. + +```go +validation.New().Field("slug", validation.AlphaDash) +// "hello-world_123" → pass, "hello world" → fail +``` + +--- + + +### AlphaNum + +```go +var AlphaNum Rule +``` + +Accepts Unicode letters and digits only. + +```go +validation.New().Field("code", validation.AlphaNum) +// "abc123" → pass, "abc-123" → fail +``` + +--- + + +### AlphaSpace + +```go +var AlphaSpace Rule +``` + +Accepts Unicode letters and whitespace only. + +```go +validation.New().Field("display_name", validation.AlphaSpace) +// "Hello World" → pass, "hello123" → fail +``` + +--- + + +### Email + +```go +var Email Rule +``` + +Validates RFC-compliant email format (no network lookup). + +```go +validation.New().Field("email", validation.Required, validation.Email) +// "user@example.com" → pass, "notanemail" → fail +``` + +--- + + +### EmailMX + +```go +var EmailMX Rule +``` + +Validates email format and performs a live DNS MX record lookup. Requires network access. + +```go +validation.New().Field("email", validation.Required, validation.EmailMX) +``` + +--- + + +### EndsWith + +```go +func EndsWith(suffix string) Rule +``` + +Fails if the value is not a string or does not end with `suffix`. + +```go +validation.New().Field("filename", validation.EndsWith(".go")) +// "main.go" → pass, "main.js" → fail +``` + +--- + + +### Length + +```go +func Length(n int) Rule +``` + +Fails if the value is not a string or its rune count is not exactly `n`. + +```go +validation.New().Field("pin", validation.Length(6)) +// "123456" → pass, "12345" → fail +``` + +--- + + +### Lowercase + +```go +var Lowercase Rule +``` + +Fails if the value is not a string or contains any uppercase characters. + +```go +validation.New().Field("username", validation.Lowercase) +// "hello" → pass, "Hello" → fail +``` + +--- + + +### MaxLength + +```go +func MaxLength(n int) Rule +``` + +Fails if the value is not a string or its rune count exceeds `n`. + +```go +validation.New().Field("bio", validation.MaxLength(500)) +``` + +--- + + +### MinLength + +```go +func MinLength(n int) Rule +``` + +Fails if the value is not a string or its rune count is less than `n`. + +```go +validation.New().Field("password", validation.MinLength(8)) +``` + +--- + + +### NotRegex + +```go +func NotRegex(pattern string) Rule +``` + +Fails if the value is not a string or the string matches the pattern. The pattern is compiled at call time; an invalid pattern causes a `RuleSyntaxError`. + +```go +validation.New().Field("username", validation.NotRegex(`\s`)) +// "nospaces" → pass, "has spaces" → fail +``` + +--- + + +### Regex + +```go +func Regex(pattern string) Rule +``` + +Fails if the value is not a string or does not match the pattern. The pattern is compiled at call time; an invalid pattern causes a `RuleSyntaxError`. + +```go +validation.New().Field("postal_code", validation.Regex(`^\d{5}$`)) +// "12345" → pass, "1234" → fail +``` + +--- + + +### StartsWith + +```go +func StartsWith(prefix string) Rule +``` + +Fails if the value is not a string or does not begin with `prefix`. + +```go +validation.New().Field("sku", validation.StartsWith("SKU-")) +// "SKU-001" → pass, "001-SKU" → fail +``` + +--- + + +### Uppercase + +```go +var Uppercase Rule +``` + +Fails if the value is not a string or contains any lowercase characters. + +```go +validation.New().Field("country_code", validation.Uppercase) +// "US" → pass, "us" → fail +``` + +--- + + +### UUID + +```go +var UUID Rule +``` + +Validates UUID format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (case-insensitive, any variant). + +```go +validation.New().Field("id", validation.UUID) +// "550e8400-e29b-41d4-a716-446655440000" → pass +``` + +--- + +## Number + + +### Between + +```go +func Between[T number](min, max T) Rule +``` + +Fails if the value cannot be type-asserted to `T` or falls outside `[min, max]` (inclusive). + +```go +validation.New().Field("age", validation.Between[int](18, 65)) +// 18 → pass, 65 → pass, 17 → fail +``` + +--- + + +### GT + +```go +func GT[T number](v T) Rule +``` + +Fails if the value is not of type `T` or is not strictly greater than `v` (equal fails). + +```go +validation.New().Field("score", validation.GT[int](0)) +// 1 → pass, 0 → fail +``` + +--- + + +### GTE + +```go +func GTE[T number](v T) Rule +``` + +Fails if the value is not of type `T` or is less than `v`. Equal values pass. Semantically identical to `Min`. + +```go +validation.New().Field("age", validation.GTE[int](18)) +// 18 → pass, 17 → fail +``` + +--- + + +### Integer + +```go +var Integer Rule +``` + +Passes if the value's Go type is one of the integer kinds (`int`, `int8`…`int64`, `uint`…`uint64`). `nil` passes. `float64` (the default JSON number type) fails. + +```go +validation.New().Field("count", validation.Integer) +// int(5) → pass, float64(5.0) → fail +``` + +--- + + +### LT + +```go +func LT[T number](v T) Rule +``` + +Fails if the value is not of type `T` or is not strictly less than `v` (equal fails). + +```go +validation.New().Field("quantity", validation.LT[int](100)) +// 99 → pass, 100 → fail +``` + +--- + + +### LTE + +```go +func LTE[T number](v T) Rule +``` + +Fails if the value is not of type `T` or is greater than `v`. Equal values pass. Semantically identical to `Max`. + +```go +validation.New().Field("rating", validation.LTE[int](5)) +// 5 → pass, 6 → fail +``` + +--- + + +### Max + +```go +func Max[T number](max T) Rule +``` + +Fails if the value is not of type `T` or exceeds `max` (inclusive upper bound, i.e. `<=`). + +```go +validation.New().Field("rating", validation.Max[int](5)) +// 5 → pass, 6 → fail +``` + +--- + + +### Min + +```go +func Min[T number](min T) Rule +``` + +Fails if the value is not of type `T` or is below `min` (inclusive lower bound, i.e. `>=`). + +```go +validation.New().Field("age", validation.Min[int](18)) +// 18 → pass, 17 → fail +``` + +--- + + +### Numeric + +```go +var Numeric Rule +``` + +Accepts any Go numeric type or a string parseable as a `float64`. Rejects booleans, `nil`, and non-numeric strings. + +```go +validation.New().Field("price", validation.Numeric) +// 42, 3.14, "99.5" → pass, "abc" → fail +``` + +--- + + +### Latitude + +```go +var Latitude Rule +``` + +Fails if the value is not a numeric type or falls outside `[-90, 90]` (inclusive). Accepts any numeric kind including `float64` (the default JSON number type). `nil` passes. + +```go +validation.New().Field("lat", validation.Latitude) +// 45.0 → pass, -90.0 → pass, 90.1 → fail +``` + +--- + + +### Longitude + +```go +var Longitude Rule +``` + +Fails if the value is not a numeric type or falls outside `[-180, 180]` (inclusive). Accepts any numeric kind. `nil` passes. + +```go +validation.New().Field("lng", validation.Longitude) +// 120.5 → pass, -180.0 → pass, 180.1 → fail +``` + +--- + + +### MultipleOf + +```go +func MultipleOf[T number](n T) Rule +``` + +Fails if the value is not a numeric type or is not evenly divisible by `n`. Accepts any numeric kind; comparison is done in `float64`. `n` must not be zero — a zero divisor causes a `RuleSyntaxError`. + +```go +validation.New().Field("quantity", validation.MultipleOf[int](5)) +// 10 → pass, float64(15) → pass, 7 → fail +``` + +--- + + +### Negative + +```go +var Negative Rule +``` + +Fails if the value is not a numeric type or is `>= 0`. `nil` passes. + +```go +validation.New().Field("offset", validation.Negative) +// -1 → pass, -0.5 → pass, 0 → fail +``` + +--- + + +### NonNegative + +```go +var NonNegative Rule +``` + +Fails if the value is not a numeric type or is `< 0`. `nil` passes. + +```go +validation.New().Field("count", validation.NonNegative) +// 0 → pass, 5 → pass, -1 → fail +``` + +--- + + +### Port + +```go +var Port Rule +``` + +Fails if the value is not a numeric type, is fractional, or falls outside `[1, 65535]`. Accepts `float64` (the default JSON number type). `nil` passes. + +```go +validation.New().Field("port", validation.Port) +// 80 → pass, float64(8080) → pass, 0 → fail, 65536 → fail +``` + +--- + + +### Positive + +```go +var Positive Rule +``` + +Fails if the value is not a numeric type or is `<= 0`. `nil` passes. + +```go +validation.New().Field("price", validation.Positive) +// 1 → pass, 0.1 → pass, 0 → fail, -1 → fail +``` + +--- + +## Digit + +> Digit rules operate on the **string representation** of a value. Non-string inputs fail. Only the characters `0–9` are counted; signs, decimal points, and other characters cause failure. + + +### Digits + +```go +func Digits(n int) Rule +``` + +Fails if the value is not a string consisting of exactly `n` digit characters. + +```go +validation.New().Field("pin", validation.Digits(4)) +// "1234" → pass, "123" → fail, "-123" → fail +``` + +--- + + +### DigitsBetween + +```go +func DigitsBetween(min, max int) Rule +``` + +Fails if the digit count is outside `[min, max]` (inclusive). + +```go +validation.New().Field("otp", validation.DigitsBetween(4, 8)) +// "1234" → pass, "123" → fail +``` + +--- + + +### MaxDigits + +```go +func MaxDigits(n int) Rule +``` + +Fails if the value is not a string of at most `n` digit characters. + +```go +validation.New().Field("code", validation.MaxDigits(6)) +// "1234" → pass, "1234567" → fail +``` + +--- + + +### MinDigits + +```go +func MinDigits(n int) Rule +``` + +Fails if the value is not a string of at least `n` digit characters. + +```go +validation.New().Field("phone", validation.MinDigits(7)) +// "1234567" → pass, "123456" → fail +``` + +--- + +## DateTime + + +### After + +```go +func After(ct time.Time) Rule +``` + +Fails if the value is not a parseable date/time string or is not strictly after `ct` (equal fails). + +```go +deadline := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) +validation.New().Field("expiry", validation.After(deadline)) +// "2024-06-01" → pass, "2024-01-01" → fail (equal) +``` + +--- + + +### AfterField + +```go +func AfterField(path string) InputRule +``` + +Cross-field rule. Fails if the value is not strictly after the date/time at `path`. Both fields must be parseable date/time strings. + +```go +validation.New(). + Field("start", validation.Required, validation.DateTime). + Field("end", validation.Required, validation.AfterField("start")) +``` + +--- + + +### AfterOrEqual + +```go +func AfterOrEqual(ct time.Time) Rule +``` + +Fails if the value is not a parseable date/time string or is strictly before `ct`. Equal values pass. + +```go +now := time.Now() +validation.New().Field("date", validation.AfterOrEqual(now)) +``` + +--- + + +### Before + +```go +func Before(ct time.Time) Rule +``` + +Fails if the value is not a parseable date/time string or is not strictly before `ct` (equal fails). + +```go +expiry := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) +validation.New().Field("birthday", validation.Before(expiry)) +``` + +--- + + +### BeforeField + +```go +func BeforeField(path string) InputRule +``` + +Cross-field rule. Fails if the value is not strictly before the date/time at `path`. + +```go +validation.New(). + Field("start", validation.Required, validation.BeforeField("end")). + Field("end", validation.Required, validation.DateTime) +``` + +--- + + +### BeforeOrEqual + +```go +func BeforeOrEqual(ct time.Time) Rule +``` + +Fails if the value is not a parseable date/time string or is strictly after `ct`. Equal values pass. + +```go +expiry := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) +validation.New().Field("valid_until", validation.BeforeOrEqual(expiry)) +``` + +--- + + +### DateTime + +```go +var DateTime Rule +``` + +Fails if the value is not a string or cannot be parsed as a date/time in any common format (RFC3339, ISO 8601, RFC1123, and many more). + +```go +validation.New().Field("created_at", validation.DateTime) +// "2024-03-15" → pass, "not-a-date" → fail +``` + +--- + + +### DateTimeBetween + +```go +func DateTimeBetween(min, max time.Time) Rule +``` + +Fails if the value is not a parseable date/time string or falls outside `[min, max]` (inclusive). + +```go +start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) +end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) +validation.New().Field("event_date", validation.DateTimeBetween(start, end)) +``` + +--- + + +### DateTimeFormat + +```go +func DateTimeFormat(layout string) Rule +``` + +Fails if the value is not a string or does not match the given Go time layout exactly. + +```go +validation.New().Field("date", validation.DateTimeFormat("2006-01-02")) +// "2024-03-15" → pass, "15/03/2024" → fail +``` + +--- + + +### Timezone + +```go +var Timezone Rule +``` + +Fails if the value is not a valid IANA timezone name (e.g. `"UTC"`, `"America/New_York"`). + +```go +validation.New().Field("tz", validation.Timezone) +// "America/New_York" → pass, "InvalidZone" → fail +``` + +--- + +## Network + + +### CIDR + +```go +var CIDR Rule +``` + +Fails if the value is not a string or cannot be parsed as a valid CIDR block (IPv4 or IPv6). + +```go +validation.New().Field("subnet", validation.CIDR) +// "192.168.0.0/24" → pass, "2001:db8::/32" → pass, "192.168.0.1" → fail +``` + +--- + + +### IP + +```go +var IP Rule +``` + +Fails if the value is not a valid IPv4 or IPv6 address string. + +```go +validation.New().Field("remote_addr", validation.IP) +// "192.168.1.1" → pass, "::1" → pass, "999.0.0.1" → fail +``` + +--- + + +### IPv4 + +```go +var IPv4 Rule +``` + +Fails if the value is not a valid IPv4 address string. IPv6 addresses fail. + +```go +validation.New().Field("ip", validation.IPv4) +// "192.168.1.1" → pass, "::1" → fail +``` + +--- + + +### IPv6 + +```go +var IPv6 Rule +``` + +Fails if the value is not a valid IPv6 address string. IPv4 addresses fail. + +```go +validation.New().Field("ip", validation.IPv6) +// "::1" → pass, "192.168.1.1" → fail +``` + +--- + + +### MACAddress + +```go +var MACAddress Rule +``` + +Validates a 6-byte MAC address. Accepted formats: `01:23:45:67:89:ab` and `01-23-45-67-89-AB` (case-insensitive). 8-byte EUI-64 addresses are rejected. + +```go +validation.New().Field("mac", validation.MACAddress) +// "01:23:45:67:89:ab" → pass, "not-a-mac" → fail +``` + +--- + + +### URL + +```go +var URL Rule +``` + +Validates an absolute URL (`https://...`, `http://...`) or a scheme-less URL where `http` is inferred. Rejects bare paths without a host. + +```go +validation.New().Field("website", validation.URL) +// "https://example.com" → pass, "not a url" → fail +``` + +--- + +## Collection + + +### Distinct + +```go +var Distinct Rule +``` + +Fails if the value is a slice or array containing duplicate elements. Non-slice values and non-comparable element types are skipped (pass). + +```go +validation.New().Field("tags", validation.Distinct) +// []string{"a","b","c"} → pass, []int{1,2,1} → fail +``` + +--- + + +### Each + +```go +func Each(rules ...Rule) Rule +``` + +Applies the given rules to every element of a slice or array. Non-slice/array values and `nil` pass. Stops at the first failing element and returns an error with code `"each"`. + +```go +validation.New().Field("emails", validation.Each(validation.Email)) +// []string{"a@b.com","c@d.com"} → pass, []string{"a@b.com","bad"} → fail +``` + +--- + + +### MaxSize + +```go +func MaxSize(n int) Rule +``` + +Fails if the value is a slice or array with more than `n` elements. Non-slice/array values and `nil` pass. + +```go +validation.New().Field("tags", validation.MaxSize(5)) +// []string{"a","b"} → pass, []string{"a","b","c","d","e","f"} → fail +``` + +--- + + +### MinSize + +```go +func MinSize(n int) Rule +``` + +Fails if the value is a slice or array with fewer than `n` elements. Non-slice/array values and `nil` pass. + +```go +validation.New().Field("items", validation.MinSize(1)) +// []int{1} → pass, []int{} → fail +``` + +--- + + +### Size + +```go +func Size(n int) Rule +``` + +Fails if the value is a slice or array whose length is not exactly `n`. Non-slice/array values and `nil` pass. + +```go +validation.New().Field("coords", validation.Size(2)) +// []float64{1.0, 2.0} → pass, []float64{1.0} → fail +``` + +--- + +## Generic + + +### In + +```go +func In[T comparable](slice []T) Rule +``` + +Fails if the value cannot be type-asserted to `T` or is not found in `slice`. Comparison uses `==`. + +```go +validation.New().Field("status", validation.In([]string{"active", "inactive", "pending"})) +// "active" → pass, "deleted" → fail +``` + +--- + + +### NEQ + +```go +func NEQ[T comparable](v T) Rule +``` + +Fails if the value cannot be type-asserted to `T` or equals `v`. Comparison is type-sensitive. + +```go +validation.New().Field("role", validation.NEQ[string]("banned")) +// "admin" → pass, "banned" → fail +``` + +--- + + +### NotIn + +```go +func NotIn[T comparable](slice []T) Rule +``` + +Fails if the value cannot be type-asserted to `T` or is found in `slice`. + +```go +validation.New().Field("username", validation.NotIn([]string{"admin", "root", "system"})) +// "alice" → pass, "admin" → fail +``` + +--- + +## Comparison + + +### Different + +```go +func Different(path string) InputRule +``` + +Fails if the value equals the value at `path` in the input. If the referenced field is absent, the rule passes. + +```go +validation.New(). + Field("new_password", validation.Required, validation.Different("old_password")) +``` + +--- + + +### SameAs + +```go +func SameAs(path string) InputRule +``` + +Fails if the value does not equal the value at `path` in the input. If the referenced field is absent, the rule fails. Comparison is type-sensitive (`==`). + +```go +validation.New(). + Field("password_confirm", validation.Required, validation.SameAs("password")) +``` + +--- + +## Logical + + +### Any + +```go +func Any(rules ...Rule) Rule +``` + +Passes when at least one of the given rules passes. All rules are tried in order; the first pass short-circuits. If every rule fails, returns an error with code `"any"`. Inner errors are not propagated. + +```go +validation.New().Field("contact", validation.Required, validation.Any(validation.Email, validation.PhoneE164)) +// "user@example.com" → pass, "+14155552671" → pass, "notvalid" → fail +``` + +--- + + +### Not + +```go +func Not(r Rule) Rule +``` + +Inverts the result of the given rule. Passes when the inner rule fails; fails (returning an error with code `"not"`) when the inner rule passes. + +```go +validation.New().Field("contact", validation.Not(validation.Email)) +// "not-an-email" → pass, "user@example.com" → fail +``` + +--- + + +### Unless + +```go +func Unless(condition string, rules ...Rule) InputRule +``` + +Applies the given rules only when the condition evaluates to `false`. The condition language is identical to `RequiredIf`. Returns `RuleSyntaxError` for a malformed condition. Inner rule errors are propagated directly. + +```go +validation.New(). + Field("reason", validation.Unless(`status == "approved"`, validation.MinLength(10))) +``` + +--- + + +### When + +```go +func When(condition string, rules ...Rule) InputRule +``` + +Applies the given rules only when the condition evaluates to `true`. The condition language is identical to `RequiredIf`. Returns `RuleSyntaxError` for a malformed condition. Inner rule errors are propagated directly. + +```go +validation.New(). + Field("vat", validation.When(`plan == "paid"`, validation.Regex(`^[A-Z]{2}\d{9}$`))) diff --git a/bag/error_bag.go b/bag/error_bag.go deleted file mode 100644 index f421110..0000000 --- a/bag/error_bag.go +++ /dev/null @@ -1,36 +0,0 @@ -package bag - -// ErrorBag is a custom type that holds a map of field selectors and their related validation errors. -type ErrorBag map[string][]string - -// IsEmpty checks whether the error bag map is empty. -func (b ErrorBag) IsEmpty() bool { - return len(b) == 0 -} - -// All returns all the errors in the error bag map. -func (b ErrorBag) All() map[string][]string { - return b -} - -// Add adds an error message (or many error messages) for the given selector. -func (b ErrorBag) Add(selector string, msg ...string) { - b[selector] = append(b[selector], msg...) -} - -// FirstOf returns the first error message of the given selector (if it exists). -func (b ErrorBag) FirstOf(selector string) string { - msg, ok := b[selector] - if !ok { - return "" - } - - return msg[0] -} - -// Has checks if there is an error for the given selector in the error bag. -func (b ErrorBag) Has(selector string) bool { - v, ok := b[selector] - - return ok && v != nil && len(v) > 0 -} diff --git a/bag/error_bag_test.go b/bag/error_bag_test.go deleted file mode 100644 index 06272f9..0000000 --- a/bag/error_bag_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package bag - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestErrorBag_IsEmpty(t *testing.T) { - errorBag := make(ErrorBag) - - assert.True(t, errorBag.IsEmpty()) - assert.Len(t, errorBag, 0) - - errorBag["test"] = []string{"error 1"} - - assert.False(t, errorBag.IsEmpty()) - assert.Len(t, errorBag, 1) -} - -func TestErrorBag_Add(t *testing.T) { - errorBag := make(ErrorBag) - selector := "selector1" - msg := "error 1 for selector 1" - - errorBag.Add(selector, msg) - - assert.Len(t, errorBag[selector], 1) - assert.Equal(t, msg, errorBag[selector][0]) -} - -func TestErrorBag_All(t *testing.T) { - errorBag := make(ErrorBag) - errorBag["test"] = []string{"error 1", "error 2", "error 3"} - errorBag["anotherTest"] = []string{"error 1"} - - assert.Len(t, errorBag.All(), 2) - assert.Len(t, errorBag.All()["test"], 3) - assert.Len(t, errorBag.All()["anotherTest"], 1) -} - -func TestErrorBag_FirstOf(t *testing.T) { - errorBag := make(ErrorBag) - errorBag["test"] = []string{"error 1", "error 2", "error 3"} - - assert.Equal(t, "error 1", errorBag.FirstOf("test")) - assert.Equal(t, "", errorBag.FirstOf("notExists")) -} - -func TestErrorBag_Has(t *testing.T) { - errorBag := make(ErrorBag) - errorBag["test"] = []string{"error 1", "error 2", "error 3"} - errorBag["anotherTest"] = []string{"error 1"} - errorBag["emptyTest"] = []string{} - errorBag["nilTest"] = nil - - assert.True(t, errorBag.Has("test")) - assert.True(t, errorBag.Has("anotherTest")) - assert.False(t, errorBag.Has("emptyTest")) - assert.False(t, errorBag.Has("emptyTest")) - assert.False(t, errorBag.Has("motExistsTest")) -} diff --git a/bag/input_bag.go b/bag/input_bag.go deleted file mode 100644 index 5c5d1d5..0000000 --- a/bag/input_bag.go +++ /dev/null @@ -1,104 +0,0 @@ -package bag - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/spf13/cast" -) - -// InputBag is a custom type representing the validation input. -type InputBag map[string]any - -// Get retrieves the value associated with the provided selector, which may reference nested values using dot notation. -// For example, "user.settings.avatar" or "users.0.email". -// It returns the value found at the specified path and a boolean indicating whether the value exists. -// The returned value will be nil and false if the path does not exist in the InputBag. -func (b InputBag) Get(selector string) (any, bool) { - parts := strings.Split(selector, ".") - - if len(parts) == 1 { - v, ok := b[selector] - return v, ok - } - - base := b[parts[0]] - for i := 1; i < len(parts); i++ { - if k, err := cast.ToIntE(parts[i]); err == nil { - tmp, err := cast.ToSliceE(base) - if err != nil || len(tmp) <= k { - return nil, false - } - - base = tmp[k] - continue - } - - tmp, ok := base.(map[string]any) - if !ok { - return nil, false - } - - v, ok := tmp[parts[i]] - if !ok { - return nil, false - } - base = v - } - - return base, base != nil -} - -// Has checks if the given selector exists in the input bag. The selector can contain dots in its value for nested values. -// For example, "user.settings.avatar" or "users.0.email". -// The returned value indicates whether the selector exists in the InputBag. -func (b InputBag) Has(selector string) bool { - parts := strings.Split(selector, ".") - - if len(parts) == 1 { - _, ok := b[selector] - return ok - } - - base := b[parts[0]] - for i := 1; i < len(parts); i++ { - if k, err := cast.ToIntE(parts[i]); err == nil { - tmp, err := cast.ToSliceE(base) - if err != nil || len(tmp) <= k { - return false - } - - base = tmp[k] - continue - } - - tmp, ok := base.(map[string]any) - if !ok { - return false - } - - v, ok := tmp[parts[i]] - if !ok { - return false - } - base = v - } - - return base != nil -} - -// NewInputBagFromStruct converts the given input struct into InputBag. -// Note that since we use json.Marshal and json.Unmarshal for this conversion, the given struct must have exported -// field, and if the exported fields have json tag, keep in mind that the InputBag keys are the same as the tags. -func NewInputBagFromStruct(input any) InputBag { - b, err := json.Marshal(input) - if err != nil { - panic(fmt.Errorf("failed to marshal input bag struct: %w", err)) - } - - var bag InputBag - _ = json.Unmarshal(b, &bag) //nolint:errcheck // no need to check error - - return bag -} diff --git a/bag/input_bag_test.go b/bag/input_bag_test.go deleted file mode 100644 index 0086aa8..0000000 --- a/bag/input_bag_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package bag - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -type sampleStruct struct { - UserName string `json:"username"` - Email string `json:"email"` - Age int `json:"age"` - Skills []string `json:"skills"` - Experiences []struct { - JobTitle string `json:"jobTitle"` - Salary int `json:"salary"` - From time.Time `json:"from"` - To time.Time `json:"to"` - } `json:"experiences"` - Socials struct { - Twitter string `json:"twitterHandle"` - Facebook string `json:"facebookUsername"` - } `json:"socials"` -} - -var inputBag = InputBag{ - "glossary": map[string]any{ - "title": "example glossary", - "GlossDiv": map[string]any{ - "title": "S", - "GlossList": []map[string]any{ - { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": map[string]any{ - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": []string{"GML", "XML"}, - }, - "GlossSee": "markup", - }, - { - "ID": "SSML", - "SortAs": "SSML", - "GlossTerm": "Standard Simplified Markup Language", - "Acronym": "SSML", - "Abbrev": "ISO 18879:1986", - "GlossDef": map[string]any{ - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": []string{"SML", "XML"}, - }, - "GlossSee": "markup", - }, - }, - }, - }, - "version": 1.0, -} - -func TestInputBag_Get(t *testing.T) { - v, ok := inputBag.Get("glossary.GlossDiv.GlossList.1.Acronym") - assert.True(t, ok) - assert.Equal(t, "SSML", v) - - v, ok = inputBag.Get("glossary.notExists") - assert.False(t, ok) - assert.Nil(t, v) - - v, ok = inputBag.Get("version") - assert.True(t, ok) - assert.Equal(t, 1.0, v) - - v, ok = inputBag.Get("notExists") - assert.False(t, ok) - assert.Nil(t, v) - - v, ok = inputBag.Get("glossary.GlossDiv.GlossList.2.Acronym") - assert.False(t, ok) - assert.Nil(t, v) - - v, ok = inputBag.Get("version.something") - assert.False(t, ok) - assert.Nil(t, v) -} - -func TestInputBag_Has(t *testing.T) { - ok := inputBag.Has("glossary.GlossDiv.GlossList.1.Acronym") - assert.True(t, ok) - - ok = inputBag.Has("glossary.notExists") - assert.False(t, ok) - - ok = inputBag.Has("version") - assert.True(t, ok) - - ok = inputBag.Has("notExists") - assert.False(t, ok) - - ok = inputBag.Has("glossary.GlossDiv.GlossList.2.Acronym") - assert.False(t, ok) - - ok = inputBag.Has("version.something") - assert.False(t, ok) -} - -func TestNewInputBagFromStruct(t *testing.T) { - data := sampleStruct{ - UserName: "username", - Email: "email@example.com", - Age: 30, - Skills: []string{"golang", "software engineering"}, - Experiences: []struct { - JobTitle string `json:"jobTitle"` - Salary int `json:"salary"` - From time.Time `json:"from"` - To time.Time `json:"to"` - }{ - { - JobTitle: "Full Stack Developer", - Salary: 50000, - From: time.Date(2018, 11, 1, 0, 0, 0, 0, time.UTC), - To: time.Date(2019, 8, 12, 0, 0, 0, 0, time.UTC), - }, - { - JobTitle: "Backend Developer", - Salary: 58000, - From: time.Date(2019, 8, 12, 0, 0, 0, 0, time.UTC), - To: time.Date(2020, 12, 28, 0, 0, 0, 0, time.UTC), - }, - { - JobTitle: "Backend Developer", - Salary: 68000, - From: time.Date(2021, 1, 10, 0, 0, 0, 0, time.UTC), - To: time.Date(2022, 8, 23, 0, 0, 0, 0, time.UTC), - }, - }, - Socials: struct { - Twitter string `json:"twitterHandle"` - Facebook string `json:"facebookUsername"` - }{ - Twitter: "johnDoe123", - Facebook: "johnDoe123", - }, - } - - result := NewInputBagFromStruct(data) - v, _ := result.Get("username") - assert.Equal(t, "username", v) - - v, _ = result.Get("skills.0") - assert.Equal(t, "golang", v) - - v, _ = result.Get("experiences.1.salary") - assert.EqualValues(t, 58000, v) - - v, _ = result.Get("socials.twitterHandle") - assert.EqualValues(t, "johnDoe123", v) -} diff --git a/collection_rules.go b/collection_rules.go new file mode 100644 index 0000000..5770ee9 --- /dev/null +++ b/collection_rules.go @@ -0,0 +1,186 @@ +package validation + +import "reflect" + +// Distinct is a Rule that validates the value is a slice or array with no duplicate elements. +// +// Non-slice/array values pass (the rule is irrelevant for scalars). Elements whose type is +// not comparable (e.g. slices, maps) are skipped rather than causing a panic. +// +// Fails if: +// - the value is a slice/array and contains at least two equal comparable elements +// +// Examples: +// +// validation.Distinct.Validate([]string{"a", "b", "c"}) // pass +// validation.Distinct.Validate([]int{1, 2, 1}) // fail — duplicate 1 +// validation.Distinct.Validate("not-a-slice") // pass — rule irrelevant +// validation.Distinct.Validate(nil) // pass — rule irrelevant +var Distinct Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil + } + + seen := make(map[any]struct{}) + for i := 0; i < rv.Len(); i++ { + elem := rv.Index(i) + if !elem.Type().Comparable() { + continue + } + + key := elem.Interface() + if _, exists := seen[key]; exists { + return basicError{"distinct", "distinct validation failed"} + } + + seen[key] = struct{}{} + } + + return nil + }, +) + +// Each returns a Rule that applies the given rules to every element of a slice or array. +// +// Non-slice/array values and nil pass (the rule is irrelevant for scalars). Validation stops at the first failing +// element and returns basicError{"each", "each validation failed"}; the index and inner error are not propagated. +// +// Fails if: +// - any element fails any of the given rules +// +// Examples: +// +// validation.Each(validation.MinLength(2)).Validate([]string{"ab", "cd"}) // pass +// validation.Each(validation.MinLength(2)).Validate([]string{"ab", "x"}) // fail — "x" fails +// validation.Each(validation.Positive).Validate([]int{1, 2, 3}) // pass +// validation.Each(validation.Positive).Validate([]int{1, -1, 3}) // fail +func Each(rules ...Rule) Rule { + return InputRuleFunc( + func(value any, input *InputBag) error { + if value == nil { + return nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil + } + + for i := 0; i < rv.Len(); i++ { + elem := rv.Index(i).Interface() + for _, r := range rules { + if err := applyRule(r, elem, input); err != nil { + return basicError{"each", "each validation failed"} + } + } + } + + return nil + }, + ) +} + +// MaxSize returns a Rule that validates a slice or array has at most n elements. +// +// Non-slice/array values and nil pass. +// +// Fails if: +// - value is a slice/array with more than n elements +// +// Examples: +// +// validation.MaxSize(3).Validate([]int{1, 2, 3}) // pass — exactly 3 +// validation.MaxSize(3).Validate([]int{1, 2, 3, 4}) // fail — 4 elements +// validation.MaxSize(3).Validate(nil) // pass +func MaxSize(n int) Rule { + return RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil + } + + if rv.Len() > n { + return maxSizeError{Size: n} + } + + return nil + }, + ) +} + +// MinSize returns a Rule that validates a slice or array has at least n elements. +// +// Non-slice/array values and nil pass. +// +// Fails if: +// - value is a slice/array with fewer than n elements +// +// Examples: +// +// validation.MinSize(2).Validate([]int{1, 2}) // pass — exactly 2 +// validation.MinSize(2).Validate([]int{1}) // fail — only 1 element +// validation.MinSize(2).Validate(nil) // pass +func MinSize(n int) Rule { + return RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil + } + + if rv.Len() < n { + return minSizeError{Size: n} + } + + return nil + }, + ) +} + +// Size returns a Rule that validates a slice or array has exactly n elements. +// +// Non-slice/array values and nil pass. +// +// Fails if: +// - value is a slice/array whose length is not exactly n +// +// Examples: +// +// validation.Size(3).Validate([]int{1, 2, 3}) // pass +// validation.Size(3).Validate([]int{1, 2}) // fail — 2 elements +// validation.Size(3).Validate(nil) // pass +func Size(n int) Rule { + return RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil + } + + if rv.Len() != n { + return sizeError{Size: n} + } + + return nil + }, + ) +} diff --git a/collection_rules_test.go b/collection_rules_test.go new file mode 100644 index 0000000..fd7f038 --- /dev/null +++ b/collection_rules_test.go @@ -0,0 +1,181 @@ +package validation + +import ( + "testing" +) + +func TestDistinct(t *testing.T) { + tests := []struct { + name string + value any + wantErr bool + }{ + {"unique strings", []string{"a", "b", "c"}, false}, + {"unique ints", []int{1, 2, 3}, false}, + {"single element", []int{1}, false}, + {"empty slice", []int{}, false}, + {"duplicate ints", []int{1, 2, 1}, true}, + {"duplicate strings", []string{"a", "b", "a"}, true}, + {"non-slice passes", "not-a-slice", false}, + {"nil passes", nil, false}, + {"scalar passes", 42, false}, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + err := Distinct.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Distinct.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "distinct" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} + +func TestMinSize(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {[]int{1, 2, 3}, false}, + {[]int{1, 2}, false}, + {[]int{1}, true}, + {[]string{}, true}, + {nil, false}, + {"not-a-slice", false}, + {42, false}, + } + for _, tt := range tests { + err := MinSize(2).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MinSize(2).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "min_size" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestMaxSize(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {[]int{1, 2}, false}, + {[]int{1, 2, 3}, false}, + {[]int{1, 2, 3, 4}, true}, + {[]string{}, false}, + {nil, false}, + {"not-a-slice", false}, + {42, false}, + } + for _, tt := range tests { + err := MaxSize(3).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MaxSize(3).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "max_size" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestSize(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {[]int{1, 2, 3}, false}, + {[]int{1, 2}, true}, + {[]int{1, 2, 3, 4}, true}, + {[]string{}, true}, + {nil, false}, + {"not-a-slice", false}, + } + for _, tt := range tests { + err := Size(3).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Size(3).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "size" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestEach(t *testing.T) { + t.Run( + "all pass", func(t *testing.T) { + err := Each(MinLength(2)).Validate([]string{"ab", "cd", "ef"}) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + }, + ) + + t.Run( + "one fails", func(t *testing.T) { + err := Each(MinLength(2)).Validate([]string{"ab", "x", "cd"}) + if err == nil { + t.Error("expected error, got nil") + } + if errorCode(err) != "each" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + + t.Run( + "nil passes", func(t *testing.T) { + err := Each(MinLength(2)).Validate(nil) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + }, + ) + + t.Run( + "non-slice passes", func(t *testing.T) { + err := Each(MinLength(2)).Validate("not-a-slice") + if err != nil { + t.Errorf("expected nil, got %v", err) + } + }, + ) + + t.Run( + "empty slice passes", func(t *testing.T) { + err := Each(MinLength(2)).Validate([]string{}) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + }, + ) +} + +func TestEach_WithInputRule(t *testing.T) { + // Verify Each dispatches InputRules correctly (cross-field dispatch keystone). + schema := New(). + Field("items", Each(MinLength(2), Lowercase)) + + t.Run( + "all valid", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"items": []string{"ab", "cd"}}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) + + t.Run( + "element fails rule", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"items": []string{"ab", "X"}}) + if !res.HasErrors() { + t.Error("expected errors, got none") + } + }, + ) +} diff --git a/comparison_rules.go b/comparison_rules.go new file mode 100644 index 0000000..1515bf3 --- /dev/null +++ b/comparison_rules.go @@ -0,0 +1,52 @@ +package validation + +// SameAs returns an InputRule that validates the value is equal to the value at the given field path. +// +// Comparison uses ==, so both value and type must match (e.g. string "1" != int 1). +// If the referenced field is absent, validation fails. +// +// Fails if: +// - the referenced field is absent +// - value != the referenced field's value +// +// Examples: +// +// schema := validation.New(). +// Field("password_confirm", validation.Required, validation.SameAs("password")) +func SameAs(path string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + other, found := input.Lookup(path) + if !found || value != other { + return sameAsError{Field: path} + } + + return nil + }, + ) +} + +// Different returns an InputRule that validates the value is not equal to the value at the given field path. +// +// Comparison uses ==, so both value and type are considered. +// If the referenced field is absent, the rule passes (no value to compare against). +// +// Fails if: +// - value == the referenced field's value +// +// Examples: +// +// schema := validation.New(). +// Field("new_password", validation.Required, validation.Different("old_password")) +func Different(path string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + other, found := input.Lookup(path) + if found && value == other { + return differentError{Field: path} + } + + return nil + }, + ) +} diff --git a/comparison_rules_test.go b/comparison_rules_test.go new file mode 100644 index 0000000..9b17204 --- /dev/null +++ b/comparison_rules_test.go @@ -0,0 +1,125 @@ +package validation + +import ( + "testing" +) + +func TestSameAs(t *testing.T) { + rule := SameAs("password") + + tests := []struct { + name string + value any + input map[string]any + wantErr bool + }{ + { + name: "matching strings", + value: "secret", + input: map[string]any{"password": "secret"}, + wantErr: false, + }, + { + name: "mismatched strings", + value: "wrong", + input: map[string]any{"password": "secret"}, + wantErr: true, + }, + { + name: "referenced field absent", + value: "secret", + input: map[string]any{}, + wantErr: true, + }, + { + name: "type mismatch", + value: "1", + input: map[string]any{"password": 1}, + wantErr: true, + }, + { + name: "both nil", + value: nil, + input: map[string]any{"password": nil}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + err := rule.ValidateWithInput(tt.value, bag) + if (err != nil) != tt.wantErr { + t.Errorf( + "SameAs(%q).ValidateWithInput(%v) error = %v, wantErr %v", + "password", + tt.value, + err, + tt.wantErr, + ) + } + if err != nil && errorCode(err) != "same_as" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} + +func TestDifferent(t *testing.T) { + rule := Different("old_password") + + tests := []struct { + name string + value any + input map[string]any + wantErr bool + }{ + { + name: "different values", + value: "newpass", + input: map[string]any{"old_password": "oldpass"}, + wantErr: false, + }, + { + name: "same values", + value: "samepass", + input: map[string]any{"old_password": "samepass"}, + wantErr: true, + }, + { + name: "referenced field absent — passes", + value: "anything", + input: map[string]any{}, + wantErr: false, + }, + { + name: "type mismatch — passes (1 != \"1\")", + value: "1", + input: map[string]any{"old_password": 1}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + err := rule.ValidateWithInput(tt.value, bag) + if (err != nil) != tt.wantErr { + t.Errorf( + "Different(%q).ValidateWithInput(%v) error = %v, wantErr %v", + "old_password", + tt.value, + err, + tt.wantErr, + ) + } + if err != nil && errorCode(err) != "different" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} diff --git a/condition.go b/condition.go new file mode 100644 index 0000000..9721332 --- /dev/null +++ b/condition.go @@ -0,0 +1,423 @@ +package validation + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "unicode" +) + +type cTokKind int + +const ( + cTokEOF cTokKind = iota + cTokAND + cTokOR + cTokNOT + cTokLParen + cTokRParen + cTokEQ + cTokNEQ + cTokLT + cTokGT + cTokLTE + cTokGTE + cTokIdent + cTokString + cTokInt + cTokFloat + cTokBool +) + +type cTok struct { + kind cTokKind + val string +} + +//nolint:gocyclo // this function is complex by nature. +func condTokenize(s string) ([]cTok, error) { + var tokens []cTok + i := 0 + for i < len(s) { + if unicode.IsSpace(rune(s[i])) { + i++ + continue + } + + switch { + case strings.HasPrefix(s[i:], "&&"): + tokens = append(tokens, cTok{cTokAND, "&&"}) + i += 2 + case strings.HasPrefix(s[i:], "||"): + tokens = append(tokens, cTok{cTokOR, "||"}) + i += 2 + case strings.HasPrefix(s[i:], "=="): + tokens = append(tokens, cTok{cTokEQ, "=="}) + i += 2 + case strings.HasPrefix(s[i:], "!="): + tokens = append(tokens, cTok{cTokNEQ, "!="}) + i += 2 + case strings.HasPrefix(s[i:], "<="): + tokens = append(tokens, cTok{cTokLTE, "<="}) + i += 2 + case strings.HasPrefix(s[i:], ">="): + tokens = append(tokens, cTok{cTokGTE, ">="}) + i += 2 + case s[i] == '<': + tokens = append(tokens, cTok{cTokLT, "<"}) + i++ + case s[i] == '>': + tokens = append(tokens, cTok{cTokGT, ">"}) + i++ + case s[i] == '!': + tokens = append(tokens, cTok{cTokNOT, "!"}) + i++ + case s[i] == '(': + tokens = append(tokens, cTok{cTokLParen, "("}) + i++ + case s[i] == ')': + tokens = append(tokens, cTok{cTokRParen, ")"}) + i++ + case s[i] == '"' || s[i] == '\'': + quote := s[i] + i++ + start := i + for i < len(s) && s[i] != quote { + i++ + } + tokens = append(tokens, cTok{cTokString, s[start:i]}) + if i < len(s) { + i++ + } + case unicode.IsDigit(rune(s[i])): + start := i + isFloat := false + for i < len(s) && (unicode.IsDigit(rune(s[i])) || s[i] == '.') { + if s[i] == '.' { + if isFloat { + return nil, fmt.Errorf("invalid numeric literal %q", s[start:i+1]) + } + isFloat = true + } + i++ + } + if isFloat { + tokens = append(tokens, cTok{cTokFloat, s[start:i]}) + } else { + tokens = append(tokens, cTok{cTokInt, s[start:i]}) + } + case unicode.IsLetter(rune(s[i])) || s[i] == '_': + start := i + for i < len(s) && (unicode.IsLetter(rune(s[i])) || unicode.IsDigit(rune(s[i])) || s[i] == '_' || s[i] == '.') { + i++ + } + word := s[start:i] + switch word { + case "true", "false": + tokens = append(tokens, cTok{cTokBool, word}) + default: + tokens = append(tokens, cTok{cTokIdent, word}) + } + default: + i++ + } + } + + tokens = append(tokens, cTok{cTokEOF, ""}) + + return tokens, nil +} + +type condParser struct { + tokens []cTok + pos int + input *InputBag +} + +func (p *condParser) peek() cTok { + if p.pos < len(p.tokens) { + return p.tokens[p.pos] + } + + return cTok{cTokEOF, ""} +} + +func (p *condParser) consume() cTok { + t := p.peek() + p.pos++ + + return t +} + +func (p *condParser) parseOr() (bool, error) { + left, err := p.parseAnd() + if err != nil { + return false, err + } + + for p.peek().kind == cTokOR { + p.consume() + + right, err := p.parseAnd() + if err != nil { + return false, err + } + + left = left || right + } + + return left, nil +} + +func (p *condParser) parseAnd() (bool, error) { + left, err := p.parseCmp() + if err != nil { + return false, err + } + + for p.peek().kind == cTokAND { + p.consume() + + right, err := p.parseCmp() + if err != nil { + return false, err + } + + left = left && right + } + + return left, nil +} + +func (p *condParser) parseCmp() (bool, error) { + left, err := p.parseUnary() + if err != nil { + return false, err + } + + opTok := p.peek() + switch opTok.kind { + case cTokEQ, cTokNEQ, cTokLT, cTokGT, cTokLTE, cTokGTE: + p.consume() + + right, err := p.parseUnary() + if err != nil { + return false, err + } + + return condCompare(left, opTok.kind, right) + } + + if b, ok := left.(bool); ok { + return b, nil + } + + return false, fmt.Errorf("value %v is not boolean and has no comparison operator", left) +} + +func (p *condParser) parseUnary() (any, error) { + if p.peek().kind == cTokNOT { + p.consume() + + val, err := p.parseAtom() + if err != nil { + return nil, err + } + + b, ok := val.(bool) + if !ok { + return nil, fmt.Errorf("! requires a boolean operand, got %T", val) + } + + return !b, nil + } + + return p.parseAtom() +} + +func (p *condParser) parseAtom() (any, error) { + t := p.peek() + switch t.kind { + case cTokLParen: + p.consume() + b, err := p.parseOr() + if err != nil { + return nil, err + } + + if p.peek().kind != cTokRParen { + return nil, errors.New("expected closing )") + } + + p.consume() + return b, nil + case cTokIdent: + if p.pos+1 < len(p.tokens) && p.tokens[p.pos+1].kind == cTokLParen { + return p.parseCall() + } + + p.consume() + val, _ := p.input.Lookup(t.val) + return val, nil + case cTokString: + p.consume() + return t.val, nil + case cTokInt: + p.consume() + n, _ := strconv.Atoi(t.val) //nolint:errcheck // no need to check we already know t.val is an int + return n, nil + + case cTokFloat: + p.consume() + f, _ := strconv.ParseFloat(t.val, 64) //nolint:errcheck // no need to check we already know t.val is a float + return f, nil + + case cTokBool: + p.consume() + return t.val == "true", nil + } + + return nil, fmt.Errorf("unexpected token %q", t.val) +} + +func (p *condParser) parseCall() (any, error) { + name := p.consume().val + p.consume() // consume "(" + + if p.peek().kind != cTokIdent { + return nil, fmt.Errorf("%s() expects a field path argument", name) + } + arg := p.consume().val + + if p.peek().kind != cTokRParen { + return nil, fmt.Errorf("expected ) after argument in %s()", name) + } + + p.consume() + switch name { + case "exists": + _, ok := p.input.Lookup(arg) + return ok, nil + case "len": + val, ok := p.input.Lookup(arg) + if !ok { + return 0, nil + } + return condLen(val), nil + default: + return nil, fmt.Errorf("unknown function %q", name) + } +} + +func condLen(val any) int { + if val == nil { + return 0 + } + + switch v := val.(type) { + case string: + return len(v) + case []any: + return len(v) + } + + rv := reflect.ValueOf(val) + switch rv.Kind() { + case reflect.Slice, reflect.Array, reflect.Map, reflect.String: + return rv.Len() + } + + return 0 +} + +func condCompare(left any, op cTokKind, right any) (bool, error) { + lf, lNum := condToFloat(left) + rf, rNum := condToFloat(right) + if lNum && rNum { + switch op { + case cTokEQ: + return lf == rf, nil + case cTokNEQ: + return lf != rf, nil + case cTokLT: + return lf < rf, nil + case cTokGT: + return lf > rf, nil + case cTokLTE: + return lf <= rf, nil + case cTokGTE: + return lf >= rf, nil + } + } + + ls := fmt.Sprintf("%v", left) + rs := fmt.Sprintf("%v", right) + switch op { + case cTokEQ: + return ls == rs, nil + case cTokNEQ: + return ls != rs, nil + case cTokLT: + return ls < rs, nil + case cTokGT: + return ls > rs, nil + case cTokLTE: + return ls <= rs, nil + case cTokGTE: + return ls >= rs, nil + default: + return false, errors.New("unsupported comparison operator") + } +} + +func condToFloat(v any) (float64, bool) { + switch n := v.(type) { + case int: + return float64(n), true + case int8: + return float64(n), true + case int16: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint: + return float64(n), true + case uint8: + return float64(n), true + case uint16: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + case float32: + return float64(n), true + case float64: + return n, true + default: + return 0, false + } +} + +func evalCondition(condition string, input *InputBag) (bool, error) { + tokens, err := condTokenize(condition) + if err != nil { + return false, err + } + + p := &condParser{tokens: tokens, input: input} + result, err := p.parseOr() + if err != nil { + return false, err + } + + if tok := p.peek(); tok.kind != cTokEOF { + return false, fmt.Errorf("unexpected token %q", tok.val) + } + + return result, nil +} diff --git a/condition_test.go b/condition_test.go new file mode 100644 index 0000000..3851825 --- /dev/null +++ b/condition_test.go @@ -0,0 +1,363 @@ +package validation + +import ( + "testing" +) + +func TestCondTokenize(t *testing.T) { + tests := []struct { + input string + wantKinds []cTokKind + wantErr bool + }{ + {"", []cTokKind{cTokEOF}, false}, + {"&&", []cTokKind{cTokAND, cTokEOF}, false}, + {"||", []cTokKind{cTokOR, cTokEOF}, false}, + {"==", []cTokKind{cTokEQ, cTokEOF}, false}, + {"!=", []cTokKind{cTokNEQ, cTokEOF}, false}, + {"<=", []cTokKind{cTokLTE, cTokEOF}, false}, + {">=", []cTokKind{cTokGTE, cTokEOF}, false}, + {"<", []cTokKind{cTokLT, cTokEOF}, false}, + {">", []cTokKind{cTokGT, cTokEOF}, false}, + {"!", []cTokKind{cTokNOT, cTokEOF}, false}, + {"(", []cTokKind{cTokLParen, cTokEOF}, false}, + {")", []cTokKind{cTokRParen, cTokEOF}, false}, + {`"hello"`, []cTokKind{cTokString, cTokEOF}, false}, + {`'world'`, []cTokKind{cTokString, cTokEOF}, false}, + {"42", []cTokKind{cTokInt, cTokEOF}, false}, + {"3.14", []cTokKind{cTokFloat, cTokEOF}, false}, + {"true", []cTokKind{cTokBool, cTokEOF}, false}, + {"false", []cTokKind{cTokBool, cTokEOF}, false}, + {"ident", []cTokKind{cTokIdent, cTokEOF}, false}, + {"a.b.c", []cTokKind{cTokIdent, cTokEOF}, false}, + {"role == admin", []cTokKind{cTokIdent, cTokEQ, cTokIdent, cTokEOF}, false}, + {"3.14.15", nil, true}, + } + + for _, tt := range tests { + t.Run( + tt.input, func(t *testing.T) { + toks, err := condTokenize(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("condTokenize(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(toks) != len(tt.wantKinds) { + t.Fatalf("got %d tokens, want %d: %v", len(toks), len(tt.wantKinds), toks) + } + for i, kind := range tt.wantKinds { + if toks[i].kind != kind { + t.Errorf("token[%d] kind = %v, want %v", i, toks[i].kind, kind) + } + } + }, + ) + } +} + +func TestEvalCondition(t *testing.T) { + tests := []struct { + name string + condition string + input map[string]any + want bool + wantErr bool + }{ + { + name: "equality true (quoted literal)", + condition: `role == "admin"`, + input: map[string]any{"role": "admin"}, + want: true, + }, + { + name: "equality false", + condition: `role == "admin"`, + input: map[string]any{"role": "user"}, + want: false, + }, + { + name: "field vs field comparison true", + condition: "role == expected", + input: map[string]any{"role": "admin", "expected": "admin"}, + want: true, + }, + { + name: "inequality true", + condition: `plan != "free"`, + input: map[string]any{"plan": "pro"}, + want: true, + }, + { + name: "inequality false", + condition: `plan != "free"`, + input: map[string]any{"plan": "free"}, + want: false, + }, + { + name: "AND both true", + condition: `role == "admin" && plan != "free"`, + input: map[string]any{"role": "admin", "plan": "pro"}, + want: true, + }, + { + name: "AND one false", + condition: `role == "admin" && plan != "free"`, + input: map[string]any{"role": "admin", "plan": "free"}, + want: false, + }, + { + name: "OR first true", + condition: `role == "admin" || role == "mod"`, + input: map[string]any{"role": "admin"}, + want: true, + }, + { + name: "OR second true", + condition: `role == "admin" || role == "mod"`, + input: map[string]any{"role": "mod"}, + want: true, + }, + { + name: "OR both false", + condition: `role == "admin" || role == "mod"`, + input: map[string]any{"role": "user"}, + want: false, + }, + { + name: "NOT true", + condition: `!(role == "admin")`, + input: map[string]any{"role": "user"}, + want: true, + }, + { + name: "NOT false", + condition: `!(role == "admin")`, + input: map[string]any{"role": "admin"}, + want: false, + }, + { + name: "bool literal true", + condition: "verified == true", + input: map[string]any{"verified": true}, + want: true, + }, + { + name: "bool literal false branch", + condition: "verified == false", + input: map[string]any{"verified": true}, + want: false, + }, + { + name: "numeric LT true", + condition: "age < 18", + input: map[string]any{"age": 15}, + want: true, + }, + { + name: "numeric LT false", + condition: "age < 18", + input: map[string]any{"age": 20}, + want: false, + }, + { + name: "numeric GT true", + condition: "age > 18", + input: map[string]any{"age": 20}, + want: true, + }, + { + name: "numeric LTE equal", + condition: "age <= 18", + input: map[string]any{"age": 18}, + want: true, + }, + { + name: "numeric GTE equal", + condition: "age >= 21", + input: map[string]any{"age": 21}, + want: true, + }, + { + name: "float comparison", + condition: "score >= 4.5", + input: map[string]any{"score": 5.0}, + want: true, + }, + { + name: "exists true", + condition: "exists(role)", + input: map[string]any{"role": "admin"}, + want: true, + }, + { + name: "exists false", + condition: "exists(role)", + input: map[string]any{}, + want: false, + }, + { + name: "dot path comparison true", + condition: "category.id == 10", + input: map[string]any{"category": map[string]any{"id": 10}}, + want: true, + }, + { + name: "dot path comparison false", + condition: "category.id == 10", + input: map[string]any{"category": map[string]any{"id": 99}}, + want: false, + }, + { + name: "dot path missing segment", + condition: `category.name == "books"`, + input: map[string]any{}, + want: false, + }, + { + name: "exists with dot path true", + condition: "exists(order.status)", + input: map[string]any{"order": map[string]any{"status": "pending"}}, + want: true, + }, + { + name: "exists with dot path false", + condition: "exists(order.status)", + input: map[string]any{"order": map[string]any{}}, + want: false, + }, + { + name: "len with dot path", + condition: "len(order.items) > 0", + input: map[string]any{"order": map[string]any{"items": []any{"a", "b"}}}, + want: true, + }, + { + name: "len string GT", + condition: "len(name) > 2", + input: map[string]any{"name": "Alice"}, + want: true, + }, + { + name: "len slice EQ", + condition: "len(tags) == 2", + input: map[string]any{"tags": []any{"a", "b"}}, + want: true, + }, + { + name: "len missing field is zero", + condition: "len(missing) == 0", + input: map[string]any{}, + want: true, + }, + { + name: "grouped expression", + condition: `(status == "active" || status == "pending") && verified == true`, + input: map[string]any{"status": "active", "verified": true}, + want: true, + }, + { + name: "grouped expression false", + condition: `(status == "active" || status == "pending") && verified == true`, + input: map[string]any{"status": "inactive", "verified": true}, + want: false, + }, + { + name: "unknown function", + condition: "unknown(field)", + input: map[string]any{}, + wantErr: true, + }, + { + name: "unclosed paren", + condition: `(role == "admin"`, + input: map[string]any{"role": "admin"}, + wantErr: true, + }, + { + name: "trailing garbage token", + condition: `role == "admin" )`, + input: map[string]any{"role": "admin"}, + wantErr: true, + }, + { + name: "non-boolean value without comparison", + condition: "role", + input: map[string]any{"role": "admin"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + got, err := evalCondition(tt.condition, bag) + if (err != nil) != tt.wantErr { + t.Fatalf("evalCondition(%q) error = %v, wantErr %v", tt.condition, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("evalCondition(%q) = %v, want %v", tt.condition, got, tt.want) + } + }, + ) + } +} + +func TestCondLen(t *testing.T) { + tests := []struct { + val any + want int + }{ + {nil, 0}, + {"hello", 5}, + {"", 0}, + {[]any{"a", "b", "c"}, 3}, + {[]int{1, 2}, 2}, + {map[string]any{"a": 1, "b": 2}, 2}, + {42, 0}, + } + + for _, tt := range tests { + got := condLen(tt.val) + if got != tt.want { + t.Errorf("condLen(%v) = %d, want %d", tt.val, got, tt.want) + } + } +} + +func TestCondToFloat(t *testing.T) { + tests := []struct { + val any + wantF float64 + wantOk bool + }{ + {int(1), 1, true}, + {int8(2), 2, true}, + {int16(3), 3, true}, + {int32(4), 4, true}, + {int64(5), 5, true}, + {uint(6), 6, true}, + {uint8(7), 7, true}, + {uint16(8), 8, true}, + {uint32(9), 9, true}, + {uint64(10), 10, true}, + {float32(1.5), float64(float32(1.5)), true}, + {float64(2.5), 2.5, true}, + {"3.14", 0, false}, + {nil, 0, false}, + {true, 0, false}, + } + + for _, tt := range tests { + f, ok := condToFloat(tt.val) + if ok != tt.wantOk { + t.Errorf("condToFloat(%v) ok = %v, want %v", tt.val, ok, tt.wantOk) + continue + } + if ok && f != tt.wantF { + t.Errorf("condToFloat(%v) = %v, want %v", tt.val, f, tt.wantF) + } + } +} diff --git a/datetime_rules.go b/datetime_rules.go new file mode 100644 index 0000000..2c05a7e --- /dev/null +++ b/datetime_rules.go @@ -0,0 +1,394 @@ +package validation + +import "time" + +var timeFormats = []string{ + "2006-01-02", + time.RFC3339, + "2006-01-02T15:04:05", + time.RFC1123Z, + time.RFC1123, + time.RFC822Z, + time.RFC822, + time.RFC850, + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02T15:04:05-0700", + "2006-01-02 15:04:05Z0700", + "2006-01-02 15:04:05", + time.ANSIC, + time.UnixDate, + time.RubyDate, + "2006-01-02 15:04:05Z07:00", + "02 Jan 2006", + "2006-01-02 15:04:05 -07:00", + "2006-01-02 15:04:05 -0700", + time.Kitchen, + time.Stamp, + time.StampMilli, + time.StampMicro, + time.StampNano, +} + +// After returns a Rule that validates the value is a date/time string occurring strictly after ct. +// +// The string is tried against a broad set of common formats (RFC3339, "2006-01-02", RFC1123, and many more) without +// requiring a specific layout. Equal timestamps are rejected; the value must be strictly after ct. +// +// Fails if: +// - value is not a string +// - the string does not match any known date/time format +// - the parsed time is equal to or before ct +// +// Examples: +// +// deadline := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) +// validation.After(deadline).Validate("2024-06-01") // pass +// validation.After(deadline).Validate("2024-01-01T00:00:00Z") // fail — equal +// validation.After(deadline).Validate("2023-12-31") // fail — before +// validation.After(deadline).Validate("not-a-date") // fail +func After(ct time.Time) Rule { + fn := func(value any) error { + str, ok := value.(string) + if !ok { + return afterError{Time: ct} + } + + t, ok := parseTime(str) + if !ok || !t.After(ct) { + return afterError{Time: ct} + } + + return nil + } + + return RuleFunc(fn) +} + +// AfterField returns an InputRule that validates the value is a date/time string occurring strictly after the +// date/time at the given field path in the input. +// +// Both the field under validation and the referenced field must be parseable date/time strings. +// If the referenced field is absent or not a string, validation fails. +// +// Fails if: +// - value is not a string or cannot be parsed +// - the referenced field is absent, not a string, or cannot be parsed +// - the parsed time is equal to or before the referenced field's time +// +// Examples: +// +// schema := validation.New(). +// Field("end", validation.AfterField("start")) +func AfterField(path string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + str, ok := value.(string) + if !ok { + return afterFieldError{Field: path} + } + + t, ok := parseTime(str) + if !ok { + return afterFieldError{Field: path} + } + + otherRaw, found := input.Lookup(path) + if !found { + return afterFieldError{Field: path} + } + + otherStr, ok := otherRaw.(string) + if !ok { + return afterFieldError{Field: path} + } + + other, ok := parseTime(otherStr) + if !ok || !t.After(other) { + return afterFieldError{Field: path} + } + + return nil + }, + ) +} + +// AfterOrEqual returns a Rule that validates the value is a date/time string occurring on or after ct. +// +// Fails if: +// - value is not a string +// - the string does not match any known date/time format +// - the parsed time is strictly before ct +// +// Examples: +// +// deadline := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) +// validation.AfterOrEqual(deadline).Validate("2024-01-01") // pass — equal +// validation.AfterOrEqual(deadline).Validate("2024-06-01") // pass — after +// validation.AfterOrEqual(deadline).Validate("2023-12-31") // fail — before +func AfterOrEqual(ct time.Time) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return afterOrEqualError{Time: ct} + } + + t, ok := parseTime(str) + if !ok || t.Before(ct) { + return afterOrEqualError{Time: ct} + } + + return nil + }, + ) +} + +// Before returns a Rule that validates the value is a date/time string occurring strictly before ct. +// +// The string is tried against a broad set of common formats (RFC3339, "2006-01-02", RFC1123, and many more) without +// requiring a specific layout. Equal timestamps are rejected; the value must be strictly before ct. +// +// Fails if: +// - value is not a string +// - the string does not match any known date/time format +// - the parsed time is equal to or after ct +// +// Examples: +// +// expiry := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) +// validation.Before(expiry).Validate("2024-06-01") // pass +// validation.Before(expiry).Validate("2025-01-01T00:00:00Z") // fail — equal +// validation.Before(expiry).Validate("2025-06-01") // fail — after +// validation.Before(expiry).Validate("not-a-date") // fail +func Before(ct time.Time) Rule { + fn := func(value any) error { + str, ok := value.(string) + if !ok { + return beforeError{Time: ct} + } + + t, ok := parseTime(str) + if !ok || !t.Before(ct) { + return beforeError{Time: ct} + } + + return nil + } + + return RuleFunc(fn) +} + +// BeforeField returns an InputRule that validates the value is a date/time string occurring strictly before the +// date/time at the given field path in the input. +// +// Both the field under validation and the referenced field must be parseable date/time strings. +// If the referenced field is absent or not a string, validation fails. +// +// Fails if: +// - value is not a string or cannot be parsed +// - the referenced field is absent, not a string, or cannot be parsed +// - the parsed time is equal to or after the referenced field's time +// +// Examples: +// +// schema := validation.New(). +// Field("start", validation.BeforeField("end")) +func BeforeField(path string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + str, ok := value.(string) + if !ok { + return beforeFieldError{Field: path} + } + + t, ok := parseTime(str) + if !ok { + return beforeFieldError{Field: path} + } + + otherRaw, found := input.Lookup(path) + if !found { + return beforeFieldError{Field: path} + } + + otherStr, ok := otherRaw.(string) + if !ok { + return beforeFieldError{Field: path} + } + + other, ok := parseTime(otherStr) + if !ok || !t.Before(other) { + return beforeFieldError{Field: path} + } + + return nil + }, + ) +} + +// BeforeOrEqual returns a Rule that validates the value is a date/time string occurring on or before ct. +// +// Fails if: +// - value is not a string +// - the string does not match any known date/time format +// - the parsed time is strictly after ct +// +// Examples: +// +// expiry := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) +// validation.BeforeOrEqual(expiry).Validate("2025-01-01") // pass — equal +// validation.BeforeOrEqual(expiry).Validate("2024-06-01") // pass — before +// validation.BeforeOrEqual(expiry).Validate("2025-06-01") // fail — after +func BeforeOrEqual(ct time.Time) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return beforeOrEqualError{Time: ct} + } + + t, ok := parseTime(str) + if !ok || t.After(ct) { + return beforeOrEqualError{Time: ct} + } + + return nil + }, + ) +} + +// DateTime is a Rule that validates the value is a recognizable date/time string. +// +// The string is tried against a broad set of common formats (the same set used by After and Before). +// No specific layout is required; any of the supported formats will pass. +// +// Fails if: +// - value is not a string +// - the string does not match any known date/time format +// +// Examples: +// +// validation.DateTime.Validate("2024-03-15") // pass +// validation.DateTime.Validate("2024-03-15T10:00:00Z") // pass +// validation.DateTime.Validate("not-a-date") // fail +var DateTime Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"date_time", "datetime validation failed"} + } + + if _, ok := parseTime(str); !ok { + return basicError{"date_time", "datetime validation failed"} + } + + return nil + }, +) + +// DateTimeBetween returns a Rule that validates the value is a date/time string occurring between min and max +// (inclusive on both ends). +// +// Fails if: +// - value is not a string +// - the string does not match any known date/time format +// - the parsed time is before min or after max +// +// Examples: +// +// start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) +// end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) +// validation.DateTimeBetween(start, end).Validate("2024-06-01") // pass +// validation.DateTimeBetween(start, end).Validate("2024-01-01") // pass — equal to min +// validation.DateTimeBetween(start, end).Validate("2023-12-31") // fail — before min +// validation.DateTimeBetween(start, end).Validate("2025-01-01") // fail — after max +func DateTimeBetween(minV, maxV time.Time) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return dateTimeBetweenError{Min: minV, Max: maxV} + } + + t, ok := parseTime(str) + if !ok || t.Before(minV) || t.After(maxV) { + return dateTimeBetweenError{Min: minV, Max: maxV} + } + + return nil + }, + ) +} + +// DateTimeFormat returns a Rule that validates the value is a string matching the given time layout. +// +// The layout uses Go's reference time: Mon Jan 2 15:04:05 MST 2006 (i.e. "2006-01-02" for ISO dates, time.RFC3339 for +// full timestamps, etc.). +// +// Fails if: +// - value is not a string +// - the string does not match the layout (wrong format, wrong separators, out-of-range values) +// +// Examples: +// +// validation.DateTimeFormat("2006-01-02").Validate("2024-03-15") // pass +// validation.DateTimeFormat(time.RFC3339).Validate("2024-03-15T10:00:00Z") // pass +// validation.DateTimeFormat("2006-01-02").Validate("15/03/2024") // fail — wrong format +// validation.DateTimeFormat("2006-01-02").Validate("not-a-date") // fail +func DateTimeFormat(layout string) Rule { + fn := func(value any) error { + str, ok := value.(string) + if !ok { + return dateTimeFormatError{Format: layout} + } + + if _, err := time.Parse(layout, str); err != nil { + return dateTimeFormatError{Format: layout} + } + + return nil + } + + return RuleFunc(fn) +} + +// Timezone is a Rule that validates the value is a valid IANA timezone name. +// +// Validation uses time.LoadLocation which recognizes IANA names (e.g. "UTC", "America/New_York", +// "Europe/London") as well as fixed-offset zones (e.g. "UTC+5"). +// +// Fails if: +// - value is not a string +// - the string is not a valid IANA timezone name +// +// Examples: +// +// validation.Timezone.Validate("UTC") // pass +// validation.Timezone.Validate("America/New_York") // pass +// validation.Timezone.Validate("Europe/London") // pass +// validation.Timezone.Validate("InvalidZone") // fail +var Timezone Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || str == "" { + return basicError{"timezone", "timezone validation failed"} + } + + if _, err := time.LoadLocation(str); err != nil { + return basicError{"timezone", "timezone validation failed"} + } + + return nil + }, +) + +func parseTime(str string) (time.Time, bool) { + for _, format := range timeFormats { + t, err := time.Parse(format, str) + if err == nil { + return t, true + } + } + + return time.Time{}, false +} diff --git a/datetime_rules_test.go b/datetime_rules_test.go new file mode 100644 index 0000000..78f70e8 --- /dev/null +++ b/datetime_rules_test.go @@ -0,0 +1,319 @@ +package validation + +import ( + "testing" + "time" +) + +var tRef = time.Date(2023, 6, 15, 12, 0, 0, 0, time.UTC) + +func TestDateTimeFormat(t *testing.T) { + tests := []struct { + value any + layout string + wantErr bool + }{ + {"2023-06-15", "2006-01-02", false}, + {"2023-06-15T12:00:00Z", time.RFC3339, false}, + {"15/06/2023", "2006-01-02", true}, + {"not a date", "2006-01-02", true}, + {"", "2006-01-02", true}, + {nil, "2006-01-02", true}, + {42, "2006-01-02", true}, + } + for _, tt := range tests { + err := DateTimeFormat(tt.layout).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("DateTimeFormat(%q).Validate(%v) error = %v, wantErr %v", tt.layout, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "date_time_format" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestAfter(t *testing.T) { + rule := After(tRef) + tests := []struct { + value any + wantErr bool + }{ + {"2024-01-01", false}, + {"2023-06-16", false}, + {"2023-06-15", true}, // equal, not after + {"2022-01-01", true}, + {"not a date", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("After(tRef).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "after" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestBefore(t *testing.T) { + rule := Before(tRef) + tests := []struct { + value any + wantErr bool + }{ + {"2022-01-01", false}, + {"2023-06-14", false}, + {"2023-06-15T12:00:00Z", true}, // exact equal, not before + {"2024-01-01", true}, + {"not a date", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Before(tRef).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "before" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestParseTime(t *testing.T) { + tests := []struct { + input string + wantOk bool + }{ + {"2023-06-15", true}, + {"2023-06-15T12:00:00Z", true}, + {"2023-06-15T12:00:00+03:30", true}, + {"Thu, 15 Jun 2023 12:00:00 +0000", true}, + {"not a date", false}, + {"", false}, + } + for _, tt := range tests { + _, ok := parseTime(tt.input) + if ok != tt.wantOk { + t.Errorf("parseTime(%q) ok = %v, want %v", tt.input, ok, tt.wantOk) + } + } +} + +func TestDateTime(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"2024-03-15", false}, + {"2024-03-15T10:00:00Z", false}, + {"Thu, 15 Jun 2023 12:00:00 +0000", false}, + {"not-a-date", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := DateTime.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("DateTime.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "date_time" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestAfterOrEqual(t *testing.T) { + rule := AfterOrEqual(tRef) + tests := []struct { + value any + wantErr bool + }{ + {"2024-01-01", false}, + {"2023-06-15T12:00:00Z", false}, // exactly equal to tRef passes + {"2022-01-01", true}, // before fails + {"2023-06-15", true}, // midnight < noon tRef → before + {"not a date", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("AfterOrEqual(tRef).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "after_or_equal" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestBeforeOrEqual(t *testing.T) { + rule := BeforeOrEqual(tRef) + tests := []struct { + value any + wantErr bool + }{ + {"2022-01-01", false}, + {"2023-06-15", false}, // equal passes + {"2024-01-01", true}, // after fails + {"not a date", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("BeforeOrEqual(tRef).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "before_or_equal" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestDateTimeBetween(t *testing.T) { + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + rule := DateTimeBetween(start, end) + tests := []struct { + value any + wantErr bool + }{ + {"2024-06-15", false}, + {"2024-01-01", false}, // equal to min + {"2024-12-31", false}, // equal to max + {"2023-12-31", true}, // before min + {"2025-01-01", true}, // after max + {"not a date", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("DateTimeBetween.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "date_time_between" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestAfterField(t *testing.T) { + rule := AfterField("start") + + t.Run( + "pass: end after start", func(t *testing.T) { + bag := NewInputBag(map[string]any{"start": "2024-01-01", "end": "2024-06-01"}) + if err := rule.ValidateWithInput("2024-06-01", bag); err != nil { + t.Errorf("unexpected error: %v", err) + } + }, + ) + + t.Run( + "fail: end equal to start", func(t *testing.T) { + bag := NewInputBag(map[string]any{"start": "2024-01-01"}) + err := rule.ValidateWithInput("2024-01-01", bag) + if err == nil { + t.Error("expected error for equal times") + } + if errorCode(err) != "after_field" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + + t.Run( + "fail: end before start", func(t *testing.T) { + bag := NewInputBag(map[string]any{"start": "2024-06-01"}) + if err := rule.ValidateWithInput("2024-01-01", bag); err == nil { + t.Error("expected error") + } + }, + ) + + t.Run( + "fail: start field absent", func(t *testing.T) { + bag := NewInputBag(map[string]any{}) + if err := rule.ValidateWithInput("2024-01-01", bag); err == nil { + t.Error("expected error for absent field") + } + }, + ) + + t.Run( + "fail: non-string value", func(t *testing.T) { + bag := NewInputBag(map[string]any{"start": "2024-01-01"}) + if err := rule.ValidateWithInput(42, bag); err == nil { + t.Error("expected error") + } + }, + ) +} + +func TestBeforeField(t *testing.T) { + rule := BeforeField("end") + + t.Run( + "pass: start before end", func(t *testing.T) { + bag := NewInputBag(map[string]any{"end": "2024-06-01"}) + if err := rule.ValidateWithInput("2024-01-01", bag); err != nil { + t.Errorf("unexpected error: %v", err) + } + }, + ) + + t.Run( + "fail: start equal to end", func(t *testing.T) { + bag := NewInputBag(map[string]any{"end": "2024-01-01"}) + err := rule.ValidateWithInput("2024-01-01", bag) + if err == nil { + t.Error("expected error for equal times") + } + if errorCode(err) != "before_field" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + + t.Run( + "fail: start after end", func(t *testing.T) { + bag := NewInputBag(map[string]any{"end": "2024-01-01"}) + if err := rule.ValidateWithInput("2024-06-01", bag); err == nil { + t.Error("expected error") + } + }, + ) +} + +func TestTimezone(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"UTC", false}, + {"America/New_York", false}, + {"Europe/London", false}, + {"Asia/Tokyo", false}, + {"InvalidZone", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Timezone.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Timezone.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "timezone" { + t.Errorf("wrong error type: %v", err) + } + } +} diff --git a/digit_rules.go b/digit_rules.go new file mode 100644 index 0000000..33f20a4 --- /dev/null +++ b/digit_rules.go @@ -0,0 +1,115 @@ +package validation + +import ( + "fmt" + "regexp" +) + +// Digits returns a Rule that validates the value is a string consisting of exactly n digit characters (0–9). +// +// Non-digit characters (signs, decimal points, letters) cause failure. The value must be a string. +// +// Fails if: +// - value is not a string +// - the string does not consist of exactly n digit characters +// +// Examples: +// +// validation.Digits(4).Validate("1234") // pass +// validation.Digits(4).Validate("12345") // fail — five digits +// validation.Digits(4).Validate("-123") // fail — sign character +// validation.Digits(4).Validate("12.3") // fail — decimal point +func Digits(n int) Rule { + re := regexp.MustCompile(fmt.Sprintf(`^\d{%d}$`, n)) + + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !re.MatchString(str) { + return digitsError{Digits: n} + } + + return nil + }, + ) +} + +// DigitsBetween returns a Rule that validates the value is a string with between min and max digit characters +// (inclusive, 0–9 only). +// +// Fails if: +// - value is not a string +// - the digit count is outside [min, max] +// +// Examples: +// +// validation.DigitsBetween(4, 6).Validate("1234") // pass +// validation.DigitsBetween(4, 6).Validate("123456") // pass +// validation.DigitsBetween(4, 6).Validate("123") // fail — too few +// validation.DigitsBetween(4, 6).Validate("1234567") // fail — too many +func DigitsBetween(minV, maxV int) Rule { + re := regexp.MustCompile(fmt.Sprintf(`^\d{%d,%d}$`, minV, maxV)) + + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !re.MatchString(str) { + return digitsBetweenError{Min: minV, Max: maxV} + } + + return nil + }, + ) +} + +// MaxDigits returns a Rule that validates the value is a string with at most n digit characters (0–9). +// +// Fails if: +// - value is not a string +// - the string has more than n digit characters (or any non-digit characters) +// +// Examples: +// +// validation.MaxDigits(6).Validate("123456") // pass +// validation.MaxDigits(6).Validate("123") // pass — fewer is fine +// validation.MaxDigits(6).Validate("1234567") // fail — seven digits +func MaxDigits(n int) Rule { + re := regexp.MustCompile(fmt.Sprintf(`^\d{1,%d}$`, n)) + + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !re.MatchString(str) { + return maxDigitsError{Digits: n} + } + + return nil + }, + ) +} + +// MinDigits returns a Rule that validates the value is a string with at least n digit characters (0–9). +// +// Fails if: +// - value is not a string +// - the string has fewer than n digit characters +// +// Examples: +// +// validation.MinDigits(4).Validate("1234") // pass +// validation.MinDigits(4).Validate("12345") // pass — more is fine +// validation.MinDigits(4).Validate("123") // fail — only three digits +func MinDigits(n int) Rule { + re := regexp.MustCompile(fmt.Sprintf(`^\d{%d,}$`, n)) + + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !re.MatchString(str) { + return minDigitsError{Digits: n} + } + + return nil + }, + ) +} diff --git a/digit_rules_test.go b/digit_rules_test.go new file mode 100644 index 0000000..e74707a --- /dev/null +++ b/digit_rules_test.go @@ -0,0 +1,117 @@ +package validation + +import ( + "testing" +) + +func TestDigits(t *testing.T) { + tests := []struct { + value any + n int + wantErr bool + }{ + {"1234", 4, false}, + {"0000", 4, false}, + {"12345", 4, true}, // too many + {"123", 4, true}, // too few + {"-123", 4, true}, // sign character + {"12.3", 4, true}, // decimal point + {"abcd", 4, true}, // letters + {"", 4, true}, + {nil, 4, true}, + {1234, 4, true}, // non-string + } + for _, tt := range tests { + err := Digits(tt.n).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Digits(%d).Validate(%v) error = %v, wantErr %v", tt.n, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "digits" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestMinDigits(t *testing.T) { + tests := []struct { + value any + n int + wantErr bool + }{ + {"1234", 4, false}, + {"12345", 4, false}, // more is fine + {"123", 4, true}, // too few + {"-1234", 4, true}, // sign character + {"", 1, true}, + {nil, 4, true}, + {1234, 4, true}, + } + for _, tt := range tests { + err := MinDigits(tt.n).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MinDigits(%d).Validate(%v) error = %v, wantErr %v", tt.n, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "min_digits" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestMaxDigits(t *testing.T) { + tests := []struct { + value any + n int + wantErr bool + }{ + {"123", 6, false}, + {"123456", 6, false}, // exactly max + {"1234567", 6, true}, // too many + {"", 6, true}, // empty string fails (no digits) + {nil, 6, true}, + {123, 6, true}, + } + for _, tt := range tests { + err := MaxDigits(tt.n).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MaxDigits(%d).Validate(%v) error = %v, wantErr %v", tt.n, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "max_digits" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestDigitsBetween(t *testing.T) { + tests := []struct { + value any + min int + max int + wantErr bool + }{ + {"1234", 4, 6, false}, + {"123456", 4, 6, false}, + {"12345", 4, 6, false}, + {"123", 4, 6, true}, // too few + {"1234567", 4, 6, true}, // too many + {"-1234", 4, 6, true}, // sign character + {"", 4, 6, true}, + {nil, 4, 6, true}, + {1234, 4, 6, true}, + } + for _, tt := range tests { + err := DigitsBetween(tt.min, tt.max).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf( + "DigitsBetween(%d,%d).Validate(%v) error = %v, wantErr %v", + tt.min, + tt.max, + tt.value, + err, + tt.wantErr, + ) + } + if err != nil && errorCode(err) != "digits_between" { + t.Errorf("wrong error type: %v", err) + } + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..e81ea08 --- /dev/null +++ b/errors.go @@ -0,0 +1,460 @@ +package validation + +import "time" + +// Error is implemented by every validation-failure error returned by the rules in this package. +// Code returns a stable snake_case key suitable for i18n catalog lookups. +// Params returns rule-specific parameters (e.g. {"length": 5} for MinLength(5), or nil for parameter-less rules). +// Custom rules may implement this interface to expose Code and Params on FieldError. +type Error interface { + error + Code() string + Params() map[string]any +} + +// ================================================================================================================== // +// basicError // +// ================================================================================================================== // + +type basicError struct { + code string + message string +} + +func (e basicError) Error() string { return e.message } +func (e basicError) Code() string { return e.code } +func (basicError) Params() map[string]any { return nil } + +// ================================================================================================================== // +// containsError // +// ================================================================================================================== // + +type containsError struct{ Substring string } + +func (containsError) Error() string { return "contains validation failed" } +func (containsError) Code() string { return "contains" } +func (e containsError) Params() map[string]any { return map[string]any{"substring": e.Substring} } + +// ================================================================================================================== // +// endsWithError // +// ================================================================================================================== // + +type endsWithError struct{ Suffix string } + +func (endsWithError) Error() string { return "ends with validation failed" } +func (endsWithError) Code() string { return "ends_with" } +func (e endsWithError) Params() map[string]any { return map[string]any{"suffix": e.Suffix} } + +// ================================================================================================================== // +// lengthError // +// ================================================================================================================== // + +type lengthError struct{ Length int } + +func (lengthError) Error() string { return "length validation failed" } +func (lengthError) Code() string { return "length" } +func (e lengthError) Params() map[string]any { return map[string]any{"length": e.Length} } + +// ================================================================================================================== // +// maxLengthError // +// ================================================================================================================== // + +type maxLengthError struct{ Length int } + +func (maxLengthError) Error() string { return "max length validation failed" } +func (maxLengthError) Code() string { return "max_length" } +func (e maxLengthError) Params() map[string]any { return map[string]any{"length": e.Length} } + +// ================================================================================================================== // +// minLengthError // +// ================================================================================================================== // + +type minLengthError struct{ Length int } + +func (minLengthError) Error() string { return "min length validation failed" } +func (minLengthError) Code() string { return "min_length" } +func (e minLengthError) Params() map[string]any { return map[string]any{"length": e.Length} } + +// ================================================================================================================== // +// notRegexError // +// ================================================================================================================== // + +type notRegexError struct{ Pattern string } + +func (notRegexError) Error() string { return "not regex validation failed" } +func (notRegexError) Code() string { return "not_regex" } +func (e notRegexError) Params() map[string]any { return map[string]any{"pattern": e.Pattern} } + +// ================================================================================================================== // +// regexError // +// ================================================================================================================== // + +type regexError struct{ Pattern string } + +func (regexError) Error() string { return "regex validation failed" } +func (regexError) Code() string { return "regex" } +func (e regexError) Params() map[string]any { return map[string]any{"pattern": e.Pattern} } + +// ================================================================================================================== // +// startsWithError // +// ================================================================================================================== // + +type startsWithError struct{ Prefix string } + +func (startsWithError) Error() string { return "starts with validation failed" } +func (startsWithError) Code() string { return "starts_with" } +func (e startsWithError) Params() map[string]any { return map[string]any{"prefix": e.Prefix} } + +// ================================================================================================================== // +// betweenError // +// ================================================================================================================== // + +type betweenError struct{ Min, Max any } + +func (betweenError) Error() string { return "between validation failed" } +func (betweenError) Code() string { return "between" } +func (e betweenError) Params() map[string]any { return map[string]any{"min": e.Min, "max": e.Max} } + +// ================================================================================================================== // +// gtError // +// ================================================================================================================== // + +type gtError struct{ Value any } + +func (gtError) Error() string { return "gt validation failed" } +func (gtError) Code() string { return "gt" } +func (e gtError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// gteError // +// ================================================================================================================== // + +type gteError struct{ Value any } + +func (gteError) Error() string { return "gte validation failed" } +func (gteError) Code() string { return "gte" } +func (e gteError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// ltError // +// ================================================================================================================== // + +type ltError struct{ Value any } + +func (ltError) Error() string { return "lt validation failed" } +func (ltError) Code() string { return "lt" } +func (e ltError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// lteError // +// ================================================================================================================== // + +type lteError struct{ Value any } + +func (lteError) Error() string { return "lte validation failed" } +func (lteError) Code() string { return "lte" } +func (e lteError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// maxError // +// ================================================================================================================== // + +type maxError struct{ Value any } + +func (maxError) Error() string { return "max validation failed" } +func (maxError) Code() string { return "max" } +func (e maxError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// minError // +// ================================================================================================================== // + +type minError struct{ Value any } + +func (minError) Error() string { return "min validation failed" } +func (minError) Code() string { return "min" } +func (e minError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// multipleOfError // +// ================================================================================================================== // + +type multipleOfError struct{ Value any } + +func (multipleOfError) Error() string { return "multiple of validation failed" } +func (multipleOfError) Code() string { return "multiple_of" } +func (e multipleOfError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// digitsError // +// ================================================================================================================== // + +type digitsError struct{ Digits int } + +func (digitsError) Error() string { return "digits validation failed" } +func (digitsError) Code() string { return "digits" } +func (e digitsError) Params() map[string]any { return map[string]any{"digits": e.Digits} } + +// ================================================================================================================== // +// digitsBetweenError // +// ================================================================================================================== // + +type digitsBetweenError struct{ Min, Max int } + +func (digitsBetweenError) Error() string { return "digits between validation failed" } +func (digitsBetweenError) Code() string { return "digits_between" } +func (e digitsBetweenError) Params() map[string]any { + return map[string]any{"min": e.Min, "max": e.Max} +} + +// ================================================================================================================== // +// maxDigitsError // +// ================================================================================================================== // + +type maxDigitsError struct{ Digits int } + +func (maxDigitsError) Error() string { return "max digits validation failed" } +func (maxDigitsError) Code() string { return "max_digits" } +func (e maxDigitsError) Params() map[string]any { return map[string]any{"digits": e.Digits} } + +// ================================================================================================================== // +// minDigitsError // +// ================================================================================================================== // + +type minDigitsError struct{ Digits int } + +func (minDigitsError) Error() string { return "min digits validation failed" } +func (minDigitsError) Code() string { return "min_digits" } +func (e minDigitsError) Params() map[string]any { return map[string]any{"digits": e.Digits} } + +// ================================================================================================================== // +// afterError // +// ================================================================================================================== // + +type afterError struct{ Time time.Time } + +func (afterError) Error() string { return "after validation failed" } +func (afterError) Code() string { return "after" } +func (e afterError) Params() map[string]any { return map[string]any{"time": e.Time} } + +// ================================================================================================================== // +// afterFieldError // +// ================================================================================================================== // + +type afterFieldError struct{ Field string } + +func (afterFieldError) Error() string { return "after field validation failed" } +func (afterFieldError) Code() string { return "after_field" } +func (e afterFieldError) Params() map[string]any { return map[string]any{"field": e.Field} } + +// ================================================================================================================== // +// afterOrEqualError // +// ================================================================================================================== // + +type afterOrEqualError struct{ Time time.Time } + +func (afterOrEqualError) Error() string { return "after or equal validation failed" } +func (afterOrEqualError) Code() string { return "after_or_equal" } +func (e afterOrEqualError) Params() map[string]any { return map[string]any{"time": e.Time} } + +// ================================================================================================================== // +// beforeError // +// ================================================================================================================== // + +type beforeError struct{ Time time.Time } + +func (beforeError) Error() string { return "before validation failed" } +func (beforeError) Code() string { return "before" } +func (e beforeError) Params() map[string]any { return map[string]any{"time": e.Time} } + +// ================================================================================================================== // +// beforeFieldError // +// ================================================================================================================== // + +type beforeFieldError struct{ Field string } + +func (beforeFieldError) Error() string { return "before field validation failed" } +func (beforeFieldError) Code() string { return "before_field" } +func (e beforeFieldError) Params() map[string]any { return map[string]any{"field": e.Field} } + +// ================================================================================================================== // +// beforeOrEqualError // +// ================================================================================================================== // + +type beforeOrEqualError struct{ Time time.Time } + +func (beforeOrEqualError) Error() string { return "before or equal validation failed" } +func (beforeOrEqualError) Code() string { return "before_or_equal" } +func (e beforeOrEqualError) Params() map[string]any { return map[string]any{"time": e.Time} } + +// ================================================================================================================== // +// dateTimeBetweenError // +// ================================================================================================================== // + +type dateTimeBetweenError struct{ Min, Max time.Time } + +func (dateTimeBetweenError) Error() string { return "datetime between validation failed" } +func (dateTimeBetweenError) Code() string { return "date_time_between" } +func (e dateTimeBetweenError) Params() map[string]any { + return map[string]any{"min": e.Min, "max": e.Max} +} + +// ================================================================================================================== // +// dateTimeFormatError // +// ================================================================================================================== // + +type dateTimeFormatError struct{ Format string } + +func (dateTimeFormatError) Error() string { return "datetime format validation failed" } +func (dateTimeFormatError) Code() string { return "date_time_format" } +func (e dateTimeFormatError) Params() map[string]any { return map[string]any{"format": e.Format} } + +// ================================================================================================================== // +// maxSizeError // +// ================================================================================================================== // + +type maxSizeError struct{ Size int } + +func (maxSizeError) Error() string { return "max size validation failed" } +func (maxSizeError) Code() string { return "max_size" } +func (e maxSizeError) Params() map[string]any { return map[string]any{"size": e.Size} } + +// ================================================================================================================== // +// minSizeError // +// ================================================================================================================== // + +type minSizeError struct{ Size int } + +func (minSizeError) Error() string { return "min size validation failed" } +func (minSizeError) Code() string { return "min_size" } +func (e minSizeError) Params() map[string]any { return map[string]any{"size": e.Size} } + +// ================================================================================================================== // +// sizeError // +// ================================================================================================================== // + +type sizeError struct{ Size int } + +func (sizeError) Error() string { return "size validation failed" } +func (sizeError) Code() string { return "size" } +func (e sizeError) Params() map[string]any { return map[string]any{"size": e.Size} } + +// ================================================================================================================== // +// inError // +// ================================================================================================================== // + +type inError struct{ Values any } + +func (inError) Error() string { return "in validation failed" } +func (inError) Code() string { return "in" } +func (e inError) Params() map[string]any { return map[string]any{"values": e.Values} } + +// ================================================================================================================== // +// notInError // +// ================================================================================================================== // + +type notInError struct{ Values any } + +func (notInError) Error() string { return "not in validation failed" } +func (notInError) Code() string { return "not_in" } +func (e notInError) Params() map[string]any { return map[string]any{"values": e.Values} } + +// ================================================================================================================== // +// neqError // +// ================================================================================================================== // + +type neqError struct{ Value any } + +func (neqError) Error() string { return "neq validation failed" } +func (neqError) Code() string { return "neq" } +func (e neqError) Params() map[string]any { return map[string]any{"value": e.Value} } + +// ================================================================================================================== // +// sameAsError // +// ================================================================================================================== // + +type sameAsError struct{ Field string } + +func (sameAsError) Error() string { return "same as validation failed" } +func (sameAsError) Code() string { return "same_as" } +func (e sameAsError) Params() map[string]any { return map[string]any{"field": e.Field} } + +// ================================================================================================================== // +// differentError // +// ================================================================================================================== // + +type differentError struct{ Field string } + +func (differentError) Error() string { return "different validation failed" } +func (differentError) Code() string { return "different" } +func (e differentError) Params() map[string]any { return map[string]any{"field": e.Field} } + +// FieldError describes a single validation failure for a single field. +// +// Err holds the underlying error returned by the rule; Message is its pre-rendered string form, kept on the struct so +// that callers do not need to invoke Err.Error() in hot paths or templates. +// Code and Params are populated when the rule error implements ValidationError; they are empty/nil for custom rules +// that do not implement that interface. +type FieldError struct { + Path string + Err error + Message string + Code string + Params map[string]any +} + +// Error implements the error interface. +func (e FieldError) Error() string { + return e.Path + ": " + e.Message +} + +// Unwrap returns the underlying rule error so that errors.Is / errors.As work. +func (e FieldError) Unwrap() error { + return e.Err +} + +// RuleSyntaxError is returned by Schema.Validate when a rule is misconfigured; for example, an invalid regex pattern +// supplied to a rule constructor. It signals a programming error rather than a validation failure; callers should treat +// it as fatal and fix the schema at startup. +type RuleSyntaxError struct { + Rule string + Err error +} + +// Error implements the error interface. +func (e RuleSyntaxError) Error() string { + if e.Rule != "" { + return "rule " + e.Rule + ": " + e.Err.Error() + } + return "rule syntax error: " + e.Err.Error() +} + +// Unwrap returns the underlying cause. +func (e RuleSyntaxError) Unwrap() error { return e.Err } + +// Result is the value returned by Schema.Validate. It carries the collection of validation failures. +// Result deliberately does not implement the error interface; call Errors to obtain the slice or HasErrors to test it. +type Result struct { + errors []FieldError +} + +// Errors returns the collected field errors. The returned slice is nil when validation succeeded. +func (r *Result) Errors() []FieldError { + return r.errors +} + +// HasErrors reports whether validation produced any errors. +func (r *Result) HasErrors() bool { + return len(r.errors) > 0 +} + +// For returns every FieldError whose path equals the given path. +func (r *Result) For(path string) []FieldError { + var out []FieldError + for _, fe := range r.errors { + if fe.Path == path { + out = append(out, fe) + } + } + return out +} diff --git a/general_rules.go b/general_rules.go new file mode 100644 index 0000000..3b38b52 --- /dev/null +++ b/general_rules.go @@ -0,0 +1,259 @@ +package validation + +import "reflect" + +// Required is a Rule that validates the value exists, by checking value is not nil or empty string. +// +// Fails if: +// - value is nil +// - value is a string equal to "" +// +// Passes for any other type, including zero values such as 0 or false — use NotEmpty if those should also be rejected. +// +// Example: +// +// schema := validation.New(). +// Field("name", validation.Required). +// Field("email", validation.Required, validation.Email) +var Required Rule = RuleFunc( + func(value any) error { + if value == nil { + return basicError{"required", "required validation failed"} + } + + s, ok := value.(string) + if ok && s == "" { + return basicError{"required", "required validation failed"} + } + + return nil + }, +) + +// RequiredIf returns a Rule that validates the value exists if the condition evaluated to true. +// +// Fails if: +// - the condition is true AND value is nil +// - the condition is true AND value is a string equal to "" +// +// Returns RuleSyntaxError if the condition string is malformed — treat that as a programming error and fix the schema +// at startup. +// +// The condition language supports: +// - comparisons: == != < > <= >= +// - logical: && || ! +// - grouping: ( expr ) +// - functions: exists(path), len(path) +// +// Field paths follow dot notation and can traverse nested maps and structs, +// e.g. "category.id", "order.shipping.country". +// String literals must be quoted ("admin" or 'admin'). +// Unquoted identifiers are resolved as field paths in the input. +// +// Examples: +// +// validation.RequiredIf(`plan == "paid"`) +// validation.RequiredIf(`role == "admin" && plan != "free"`) +// validation.RequiredIf(`exists(role) && len(tags) > 0`) +// validation.RequiredIf(`(status == "active" || status == "pending") && verified == true`) +// validation.RequiredIf(`category.id == 10`) +// validation.RequiredIf(`exists(order.shipping.address) && order.shipping.country == "US"`) +func RequiredIf(condition string) InputRule { + fn := func(value any, input *InputBag) error { + ok, err := evalCondition(condition, input) + if err != nil { + return RuleSyntaxError{Rule: "RequiredIf", Err: err} + } + + if ok { + if value == nil { + return basicError{"required_if", "required if validation failed"} + } + if s, isStr := value.(string); isStr && s == "" { + return basicError{"required_if", "required if validation failed"} + } + } + + return nil + } + + return InputRuleFunc(fn) +} + +// RequiredUnless returns an InputRule that validates the value exists unless the given condition evaluates to true. +// +// It is the logical complement of RequiredIf: the field is required when the condition is FALSE. +// The condition language is identical to RequiredIf (comparisons, &&, ||, !, exists(), len()). +// +// Returns RuleSyntaxError if the condition string is malformed. +// +// Examples: +// +// validation.RequiredUnless(`type == "guest"`) +// validation.RequiredUnless(`role == "admin"`) +func RequiredUnless(condition string) InputRule { + fn := func(value any, input *InputBag) error { + ok, err := evalCondition(condition, input) + if err != nil { + return RuleSyntaxError{Rule: "RequiredUnless", Err: err} + } + + if !ok { + if value == nil { + return basicError{"required_unless", "required unless validation failed"} + } + if s, isStr := value.(string); isStr && s == "" { + return basicError{"required_unless", "required unless validation failed"} + } + } + + return nil + } + + return InputRuleFunc(fn) +} + +// RequiredWith returns an InputRule that validates the value exists if any of the given fields are present in the input. +// +// The field under validation is optional unless at least one of the listed fields is present. +// +// Examples: +// +// validation.RequiredWith("phone", "mobile") +func RequiredWith(fields ...string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + for _, f := range fields { + if _, found := input.Lookup(f); found { + if value == nil { + return basicError{"required_with", "required with validation failed"} + } + if s, ok := value.(string); ok && s == "" { + return basicError{"required_with", "required with validation failed"} + } + + return nil + } + } + + return nil + }, + ) +} + +// RequiredWithAll returns an InputRule that validates the value exists if all of the given fields are present in +// the input. +// +// The field under validation is optional unless every listed field is present. +// +// Examples: +// +// validation.RequiredWithAll("first_name", "last_name") +func RequiredWithAll(fields ...string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + for _, f := range fields { + if _, found := input.Lookup(f); !found { + return nil + } + } + + if value == nil { + return basicError{"required_with_all", "required with all validation failed"} + } + if s, ok := value.(string); ok && s == "" { + return basicError{"required_with_all", "required with all validation failed"} + } + + return nil + }, + ) +} + +// RequiredWithout returns an InputRule that validates the value exists if any of the given fields are absent from +// the input. +// +// The field under validation is optional only when all listed fields are present. +// +// Examples: +// +// validation.RequiredWithout("email", "phone") +func RequiredWithout(fields ...string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + for _, f := range fields { + if _, found := input.Lookup(f); !found { + if value == nil { + return basicError{"required_without", "required without validation failed"} + } + if s, ok := value.(string); ok && s == "" { + return basicError{"required_without", "required without validation failed"} + } + + return nil + } + } + + return nil + }, + ) +} + +// RequiredWithoutAll returns an InputRule that validates the value exists if all of the given fields are absent from +// the input. +// +// The field under validation is optional as long as at least one of the listed fields is present. +// +// Examples: +// +// validation.RequiredWithoutAll("email", "phone") +func RequiredWithoutAll(fields ...string) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + for _, f := range fields { + if _, found := input.Lookup(f); found { + return nil + } + } + + if value == nil { + return basicError{"required_without_all", "required without all validation failed"} + } + if s, ok := value.(string); ok && s == "" { + return basicError{"required_without_all", "required without all validation failed"} + } + + return nil + }, + ) +} + +// NotEmpty is a Rule that validates the value is not an empty or zero value. +// +// Fails if: +// - value is nil +// - value is "" (empty string) +// - value is 0 (any numeric type) +// - value is false +// - value is a zero-value struct +// +// Unlike Required, NotEmpty rejects all zero values, not just nil and "". +// +// Example: +// +// schema := validation.New(). +// Field("count", validation.NotEmpty). // rejects 0 +// Field("active", validation.NotEmpty) // rejects false +var NotEmpty Rule = RuleFunc( + func(value any) error { + if value == nil { + return basicError{"not_empty", "not empty validation failed"} + } + + if reflect.ValueOf(value).IsZero() { + return basicError{"not_empty", "not empty validation failed"} + } + + return nil + }, +) diff --git a/general_rules_test.go b/general_rules_test.go new file mode 100644 index 0000000..977b65b --- /dev/null +++ b/general_rules_test.go @@ -0,0 +1,333 @@ +package validation + +import ( + "errors" + "testing" +) + +func TestRequired(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello", false}, + {"0", false}, + {0, false}, + {false, false}, + {nil, true}, + {"", true}, + } + for _, tt := range tests { + err := Required.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Required.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "required" { + t.Errorf("Required.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestRequiredIf(t *testing.T) { + tests := []struct { + name string + condition string + value any + input map[string]any + wantErr bool + wantCode string + }{ + { + name: "condition true, value present", + condition: `role == "admin"`, + value: "something", + input: map[string]any{"role": "admin"}, + wantErr: false, + }, + { + name: "condition true, value nil", + condition: `role == "admin"`, + value: nil, + input: map[string]any{"role": "admin"}, + wantErr: true, + wantCode: "required_if", + }, + { + name: "condition true, value empty string", + condition: `role == "admin"`, + value: "", + input: map[string]any{"role": "admin"}, + wantErr: true, + wantCode: "required_if", + }, + { + name: "condition false, value nil", + condition: `role == "admin"`, + value: nil, + input: map[string]any{"role": "user"}, + wantErr: false, + }, + { + name: "compound condition true", + condition: `role == "admin" && exists(plan)`, + value: nil, + input: map[string]any{"role": "admin", "plan": "pro"}, + wantErr: true, + wantCode: "required_if", + }, + { + name: "syntax error returns RuleSyntaxError", + condition: "unknown(field)", + value: nil, + input: map[string]any{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + rule := RequiredIf(tt.condition) + bag := NewInputBag(tt.input) + err := rule.ValidateWithInput(tt.value, bag) + + if (err != nil) != tt.wantErr { + t.Fatalf( + "RequiredIf(%q).ValidateWithInput(%v) error = %v, wantErr %v", + tt.condition, + tt.value, + err, + tt.wantErr, + ) + } + if tt.wantCode != "" && errorCode(err) != tt.wantCode { + t.Errorf("error code = %q, want %q", errorCode(err), tt.wantCode) + } + if tt.condition == "unknown(field)" && err != nil { + var syntaxErr RuleSyntaxError + if !errors.As(err, &syntaxErr) { + t.Errorf("expected RuleSyntaxError, got %T: %v", err, err) + } + } + }, + ) + } +} + +func TestRequiredUnless(t *testing.T) { + tests := []struct { + name string + condition string + value any + input map[string]any + wantErr bool + wantCode string + }{ + { + name: "condition true → field optional, nil passes", + condition: `type == "guest"`, + value: nil, + input: map[string]any{"type": "guest"}, + wantErr: false, + }, + { + name: "condition false → field required, nil fails", + condition: `type == "guest"`, + value: nil, + input: map[string]any{"type": "member"}, + wantErr: true, + wantCode: "required_unless", + }, + { + name: "condition false → field required, present passes", + condition: `type == "guest"`, + value: "value", + input: map[string]any{"type": "member"}, + wantErr: false, + }, + { + name: "invalid condition → RuleSyntaxError", + condition: "unknown(field)", + value: nil, + input: map[string]any{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + rule := RequiredUnless(tt.condition) + bag := NewInputBag(tt.input) + err := rule.ValidateWithInput(tt.value, bag) + + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantCode != "" && errorCode(err) != tt.wantCode { + t.Errorf("error code = %q, want %q", errorCode(err), tt.wantCode) + } + }, + ) + } +} + +func TestRequiredWith(t *testing.T) { + tests := []struct { + name string + fields []string + value any + input map[string]any + wantErr bool + }{ + {"any present, value present → pass", []string{"phone"}, "val", map[string]any{"phone": "123"}, false}, + {"any present, value nil → fail", []string{"phone"}, nil, map[string]any{"phone": "123"}, true}, + {"none present, value nil → pass", []string{"phone"}, nil, map[string]any{}, false}, + {"second present, value nil → fail", []string{"email", "phone"}, nil, map[string]any{"phone": "123"}, true}, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + err := RequiredWith(tt.fields...).ValidateWithInput(tt.value, bag) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && errorCode(err) != "required_with" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} + +func TestRequiredWithAll(t *testing.T) { + tests := []struct { + name string + fields []string + value any + input map[string]any + wantErr bool + }{ + {"all present, value present → pass", []string{"a", "b"}, "val", map[string]any{"a": 1, "b": 2}, false}, + {"all present, value nil → fail", []string{"a", "b"}, nil, map[string]any{"a": 1, "b": 2}, true}, + {"one missing, value nil → pass", []string{"a", "b"}, nil, map[string]any{"a": 1}, false}, + {"none present, value nil → pass", []string{"a", "b"}, nil, map[string]any{}, false}, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + err := RequiredWithAll(tt.fields...).ValidateWithInput(tt.value, bag) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && errorCode(err) != "required_with_all" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} + +func TestRequiredWithout(t *testing.T) { + tests := []struct { + name string + fields []string + value any + input map[string]any + wantErr bool + }{ + {"one absent, value nil → fail", []string{"email", "phone"}, nil, map[string]any{"email": "a@b.com"}, true}, + { + "one absent, value present → pass", + []string{"email", "phone"}, + "val", + map[string]any{"email": "a@b.com"}, + false, + }, + { + "all present, value nil → pass", + []string{"email", "phone"}, + nil, + map[string]any{"email": "a@b.com", "phone": "123"}, + false, + }, + {"all absent, value nil → fail", []string{"email", "phone"}, nil, map[string]any{}, true}, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + err := RequiredWithout(tt.fields...).ValidateWithInput(tt.value, bag) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && errorCode(err) != "required_without" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} + +func TestRequiredWithoutAll(t *testing.T) { + tests := []struct { + name string + fields []string + value any + input map[string]any + wantErr bool + }{ + {"all absent, value nil → fail", []string{"email", "phone"}, nil, map[string]any{}, true}, + {"all absent, value present → pass", []string{"email", "phone"}, "val", map[string]any{}, false}, + {"one present, value nil → pass", []string{"email", "phone"}, nil, map[string]any{"email": "a@b.com"}, false}, + { + "all present, value nil → pass", + []string{"email", "phone"}, + nil, + map[string]any{"email": "a@b.com", "phone": "123"}, + false, + }, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + bag := NewInputBag(tt.input) + err := RequiredWithoutAll(tt.fields...).ValidateWithInput(tt.value, bag) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && errorCode(err) != "required_without_all" { + t.Errorf("wrong error type: %v", err) + } + }, + ) + } +} + +func TestNotEmpty(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello", false}, + {1, false}, + {true, false}, + {0.1, false}, + {nil, true}, + {"", true}, + {0, true}, + {false, true}, + {0.0, true}, + } + for _, tt := range tests { + err := NotEmpty.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("NotEmpty.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "not_empty" { + t.Errorf("NotEmpty.Validate(%v) wrong error: %v", tt.value, err) + } + } +} diff --git a/generic_rules.go b/generic_rules.go new file mode 100644 index 0000000..f7157d5 --- /dev/null +++ b/generic_rules.go @@ -0,0 +1,85 @@ +package validation + +import "slices" + +// In returns a Rule that validates the value is present in the given slice. +// +// Comparison uses ==, so T must be a comparable type. The value must be exactly f type T; a float64 does not match an +// int slice even if numerically equal. +// +// Fails if: +// - value cannot be type-asserted to T +// - value is not found in slice +// +// Examples: +// +// validation.In([]string{"a", "b", "c"}).Validate("a") // pass +// validation.In([]string{"a", "b", "c"}).Validate("d") // fail — not in list +// validation.In([]int{1, 2, 3}).Validate(2) // pass +// validation.In([]int{1, 2, 3}).Validate(float64(2)) // fail — wrong type +func In[T comparable](slice []T) Rule { + fn := func(value any) error { + v, ok := value.(T) + if !ok || !slices.Contains(slice, v) { + return inError{Values: slice} + } + + return nil + } + + return RuleFunc(fn) +} + +// NEQ returns a Rule that validates the value is not equal to v. +// +// Comparison is type-sensitive: a float64(1) does not equal an int(1). +// +// Fails if: +// - value cannot be type-asserted to T +// - value == v +// +// Examples: +// +// validation.NEQ[string]("admin").Validate("user") // pass +// validation.NEQ[string]("admin").Validate("admin") // fail +// validation.NEQ[int](0).Validate(1) // pass +// validation.NEQ[int](0).Validate(0) // fail +func NEQ[T comparable](v T) Rule { + return RuleFunc( + func(value any) error { + actual, ok := value.(T) + if !ok || actual == v { + return neqError{Value: v} + } + + return nil + }, + ) +} + +// NotIn returns a Rule that validates the value is not present in the given slice. +// +// Like In, comparison uses ==. The value must be exactly of type T. +// +// Fails if: +// - value cannot be type-asserted to T +// - value is found in slice +// +// Examples: +// +// validation.NotIn([]string{"banned", "blocked"}).Validate("allowed") // pass +// validation.NotIn([]string{"banned", "blocked"}).Validate("banned") // fail — in list +// validation.NotIn([]int{0, -1}).Validate(5) // pass +// validation.NotIn([]int{0, -1}).Validate(0) // fail — in list +func NotIn[T comparable](slice []T) Rule { + fn := func(value any) error { + v, ok := value.(T) + if !ok || slices.Contains(slice, v) { + return notInError{Values: slice} + } + + return nil + } + + return RuleFunc(fn) +} diff --git a/generic_rules_test.go b/generic_rules_test.go new file mode 100644 index 0000000..9ef17dd --- /dev/null +++ b/generic_rules_test.go @@ -0,0 +1,130 @@ +package validation + +import ( + "testing" +) + +func TestIn(t *testing.T) { + t.Run( + "string", func(t *testing.T) { + rule := In([]string{"a", "b", "c"}) + tests := []struct { + value any + wantErr bool + }{ + {"a", false}, + {"b", false}, + {"c", false}, + {"d", true}, + {"", true}, + {nil, true}, + {1, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("In(strings).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "in" { + t.Errorf("wrong error type: %v", err) + } + } + }, + ) + + t.Run( + "int", func(t *testing.T) { + rule := In([]int{1, 2, 3}) + tests := []struct { + value any + wantErr bool + }{ + {1, false}, + {3, false}, + {4, true}, + {float64(1), true}, // wrong type + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("In(ints).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } + }, + ) +} + +func TestNotIn(t *testing.T) { + t.Run( + "string", func(t *testing.T) { + rule := NotIn([]string{"banned", "forbidden"}) + tests := []struct { + value any + wantErr bool + }{ + {"allowed", false}, + {"ok", false}, + {"banned", true}, + {"forbidden", true}, + {nil, true}, // type mismatch returns error per current behavior + {42, true}, // type mismatch returns error per current behavior + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("NotIn(strings).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } + }, + ) +} + +func TestNEQ(t *testing.T) { + t.Run( + "string", func(t *testing.T) { + rule := NEQ[string]("admin") + tests := []struct { + value any + wantErr bool + }{ + {"user", false}, + {"superadmin", false}, + {"admin", true}, + {"", false}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("NEQ[string](\"admin\").Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "neq" { + t.Errorf("wrong error type: %v", err) + } + } + }, + ) + + t.Run( + "int", func(t *testing.T) { + rule := NEQ[int](0) + tests := []struct { + value any + wantErr bool + }{ + {1, false}, + {-1, false}, + {0, true}, + {float64(0), true}, // wrong type + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("NEQ[int](0).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } + }, + ) +} diff --git a/go.mod b/go.mod index 5e2ffa4..c4f8bd1 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,3 @@ -module github.com/behzadsh/go.validator +module github.com/behzadsh/go.validator/v2 -go 1.18 - -require ( - github.com/behzadsh/go.localization v1.0.0 - github.com/spf13/cast v1.5.0 - github.com/stretchr/testify v1.8.1 - github.com/thoas/go-funk v0.9.2 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +go 1.21 diff --git a/go.sum b/go.sum index b8820ae..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,33 +0,0 @@ -github.com/behzadsh/go.localization v1.0.0 h1:tKComBbksYGsbExZvTRIq1WqW/7ITQpIoezpiK5ge5I= -github.com/behzadsh/go.localization v1.0.0/go.mod h1:4d37qq5QyJw/zygHl0mNPQ7p9tAv2gHnqjWkLDKQaJ0= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/thoas/go-funk v0.9.2 h1:oKlNYv0AY5nyf9g+/GhMgS/UO2ces0QRdPKwkhY3VCk= -github.com/thoas/go-funk v0.9.2/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..729a382 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,11 @@ +package validation + +import "errors" + +func errorCode(err error) string { + var ve Error + if errors.As(err, &ve) { + return ve.Code() + } + return "" +} diff --git a/init.go b/init.go deleted file mode 100644 index 43df881..0000000 --- a/init.go +++ /dev/null @@ -1,108 +0,0 @@ -package validation - -import "github.com/behzadsh/go.validator/rules" - -var defaultLocale string - -var stopOnFirstFailure bool - -var registry map[string]rules.Rule - -func init() { - // initiate with default rules - registerDefaultRules() - - defaultLocale = "en" - stopOnFirstFailure = false -} - -// SetDefaultLocale sets the default locale for validation error translations. -// The default locale is "en". -// -// Example: SetDefaultLocale("es") will set Spanish as the default language for validation errors. -func SetDefaultLocale(locale string) { - defaultLocale = locale -} - -// StopOnFirstFailure sets an option to stop validation process when the first validation occurs. -// The stop on first failure is false by default. -// -// Example: StopOnFirstFailure() will stop the validation process when the first validation occurs. -func StopOnFirstFailure() { - stopOnFirstFailure = true -} - -// Register registers a rule object with given rule name in rule registry. -// You can register a custom rule with this function. Note that registering a custom rule with rule name that already -// exists will override the default rule. -// -// Example: Register("custom", &CustomRule{}) will register a custom rule with the name "custom". -func Register(ruleName string, rule rules.Rule) { - registry[ruleName] = rule -} - -func registerDefaultRules() { - registry = map[string]rules.Rule{ - "after": &rules.After{}, - "afterOrEqual": &rules.AfterOrEqual{}, - "alpha": &rules.Alpha{}, - "alphaDash": &rules.AlphaDash{}, - "alphaNum": &rules.AlphaNum{}, - "alphaSpace": &rules.AlphaSpace{}, - "array": &rules.Array{}, - "before": &rules.Before{}, - "beforeOrEqual": &rules.BeforeOrEqual{}, - "between": &rules.Between{}, - "boolean": &rules.Boolean{}, - "dateTime": &rules.DateTime{}, - "dateTimeAfter": &rules.DateTimeAfter{}, - "dateTimeBefore": &rules.DateTimeBefore{}, - "dateTimeBetween": &rules.DateTimeBetween{}, - "dateTimeFormat": &rules.DateTimeFormat{}, - "different": &rules.Different{}, - "digits": &rules.Digits{}, - "digitsBetween": &rules.DigitsBetween{}, - "distinct": &rules.Distinct{}, - "email": &rules.Email{}, - "endsWith": &rules.EndsWith{}, - "gt": &rules.GreaterThan{}, - "gte": &rules.GreaterThanEqual{}, - "inArrayField": &rules.InArrayField{}, - "in": &rules.In{}, - "integer": &rules.Integer{}, - "ip": &rules.IP{}, - "ipv4": &rules.IPv4{}, - "ipv6": &rules.IPv6{}, - "length": &rules.Length{}, - "lowercase": &rules.Lowercase{}, - "lt": &rules.LessThan{}, - "lte": &rules.LessThanEqual{}, - "macAddress": &rules.MacAddress{}, - "max": &rules.Max{}, - "maxDigits": &rules.MaxDigits{}, - "maxLength": &rules.MaxLength{}, - "min": &rules.Min{}, - "minDigits": &rules.MinDigits{}, - "minLength": &rules.MinLength{}, - "neq": &rules.NotEqual{}, - "notEmpty": &rules.NotEmpty{}, - "notIn": &rules.NotIn{}, - "notRegex": &rules.NotRegex{}, - "numeric": &rules.Numeric{}, - "regex": &rules.Regex{}, - "required": &rules.Required{}, - "requiredIf": &rules.RequiredIf{}, - "requiredUnless": &rules.RequiredUnless{}, - "requiredWith": &rules.RequiredWith{}, - "requiredWithAll": &rules.RequiredWithAll{}, - "requiredWithout": &rules.RequiredWithout{}, - "requiredWithoutAll": &rules.RequiredWithoutAll{}, - "sameAs": &rules.SameAs{}, - "startsWith": &rules.StartsWith{}, - "string": &rules.String{}, - "timezone": &rules.Timezone{}, - "uppercase": &rules.Uppercase{}, - "url": &rules.URL{}, - "uuid": &rules.UUID{}, - } -} diff --git a/init_test.go b/init_test.go deleted file mode 100644 index 992e096..0000000 --- a/init_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/rules" -) - -func TestSetDefaultLocale(t *testing.T) { - SetDefaultLocale("es") - - assert.Equal(t, "es", defaultLocale) -} - -func TestStopOnFirstFailure(t *testing.T) { - StopOnFirstFailure() - - assert.True(t, stopOnFirstFailure) -} - -func TestRegister(t *testing.T) { - // empty registry - registry = map[string]rules.Rule{} - - rule := &rules.After{} - Register("after", rule) - - assert.Equal(t, rule, registry["after"]) - assert.NotEqual(t, rule, registry["something"]) -} diff --git a/input_bag.go b/input_bag.go new file mode 100644 index 0000000..3620567 --- /dev/null +++ b/input_bag.go @@ -0,0 +1,142 @@ +package validation + +import ( + "reflect" + "strings" +) + +// InputBag wraps the raw input passed to Schema.Validate and provides path-based field access. Paths use dot notation, +// e.g. "user.profile.email", and can traverse nested maps, structs, and pointers in any combination. +// +// InputBag is constructed once per Validate call and then passed read-only to every InputRule. Callers outside the +// validation package typically obtain one from the input parameter of InputRuleFunc — there is no need to construct +// an InputBag directly. +type InputBag struct { + input any +} + +// NewInputBag wraps input in an InputBag. The input may be a map[string]any, a struct, a pointer to a struct, +// or any nested combination thereof. +func NewInputBag(input any) *InputBag { + return &InputBag{input: input} +} + +// Lookup resolves a dot-notation path against the wrapped input and returns the value at that path together with +// a boolean indicating whether the path was found. +// +// Returning (nil, false) means the path does not exist in the input. +// Returning (nil, true) means the path exists but its value is nil. +// +// Supported segment transitions at each dot: +// - map[string]any → key lookup +// - any other map with string keys → reflect-based key lookup +// - struct or *struct → field resolved by json tag, then by Go field name +// - pointer / interface → automatically dereferenced +// +// Returning false when any segment is missing or a non-traversable value, e.g. a scalar, is encountered before the path +// is fully consumed. +func (b *InputBag) Lookup(path string) (any, bool) { + if path == "" { + return nil, false + } + + current := b.input + for _, segment := range strings.Split(path, ".") { + var ok bool + current, ok = step(current, segment) + if !ok { + return nil, false + } + } + + return current, true +} + +// step advances one segment of a dot-notation path against the current value. It handles map[string]any directly for +// performance, then falls back to reflection for other map types and structs. +func step(current any, segment string) (any, bool) { + if current == nil { + return nil, false + } + + if m, ok := current.(map[string]any); ok { + v, exists := m[segment] + return v, exists + } + + rv := reflect.ValueOf(current) + for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return nil, false + } + rv = rv.Elem() + } + + switch rv.Kind() { + case reflect.Map: + if rv.Type().Key().Kind() != reflect.String { + return nil, false + } + v := rv.MapIndex(reflect.ValueOf(segment)) + if !v.IsValid() { + return nil, false + } + return v.Interface(), true + case reflect.Struct: + return structField(rv, segment) + default: + return nil, false + } +} + +// structField resolves a single path segment against a struct value. +// +// Resolution order: +// 1. The first comma-segment of the `json` tag (skipped when the tag is "-"). +// 2. The exported Go field name. +// +// Anonymous (embedded) struct fields are searched recursively so that promoted fields are visible to the same rules +// as top-level ones. +func structField(rv reflect.Value, name string) (any, bool) { + if rv.Kind() != reflect.Struct { + return nil, false + } + + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + if field.Anonymous { + fv := rv.Field(i) + if fv.Kind() == reflect.Pointer { + if fv.IsNil() { + continue + } + fv = fv.Elem() + } + if fv.Kind() == reflect.Struct { + if v, ok := structField(fv, name); ok { + return v, true + } + } + + continue + } + if !field.IsExported() { + continue + } + if tag, ok := field.Tag.Lookup("json"); ok { + tagName := strings.Split(tag, ",")[0] + if tagName == "-" { + continue + } + if tagName != "" && tagName == name { + return rv.Field(i).Interface(), true + } + } + if field.Name == name { + return rv.Field(i).Interface(), true + } + } + + return nil, false +} diff --git a/input_bag_test.go b/input_bag_test.go new file mode 100644 index 0000000..bcb4148 --- /dev/null +++ b/input_bag_test.go @@ -0,0 +1,162 @@ +package validation + +import ( + "testing" +) + +func TestInputBagLookup_Map(t *testing.T) { + input := map[string]any{ + "name": "Alice", + "age": 30, + "profile": map[string]any{ + "email": "alice@example.com", + "address": map[string]any{ + "city": "Berlin", + }, + }, + "nilval": nil, + } + bag := NewInputBag(input) + + tests := []struct { + path string + wantVal any + wantFound bool + }{ + {"name", "Alice", true}, + {"age", 30, true}, + {"profile.email", "alice@example.com", true}, + {"profile.address.city", "Berlin", true}, + {"nilval", nil, true}, + {"missing", nil, false}, + {"profile.missing", nil, false}, + {"profile.email.deep", nil, false}, + {"", nil, false}, + } + + for _, tt := range tests { + val, found := bag.Lookup(tt.path) + if found != tt.wantFound { + t.Errorf("Lookup(%q) found = %v, want %v", tt.path, found, tt.wantFound) + } + if found && val != tt.wantVal { + t.Errorf("Lookup(%q) val = %v, want %v", tt.path, val, tt.wantVal) + } + } +} + +func TestInputBagLookup_Struct(t *testing.T) { + type Address struct { + City string `json:"city"` + } + type Profile struct { + Email string `json:"email"` + Address Address `json:"address"` + Hidden string `json:"-"` + NoTag string + } + type User struct { + Name string `json:"name"` + Profile Profile `json:"profile"` + } + + u := User{ + Name: "Bob", + Profile: Profile{ + Email: "bob@example.com", + Address: Address{City: "Paris"}, + Hidden: "secret", + NoTag: "visible", + }, + } + + bag := NewInputBag(u) + + tests := []struct { + path string + wantVal any + wantFound bool + }{ + {"name", "Bob", true}, + {"profile.email", "bob@example.com", true}, + {"profile.address.city", "Paris", true}, + {"profile.NoTag", "visible", true}, + {"profile.Hidden", nil, false}, // json:"-" hides the field + {"missing", nil, false}, + } + + for _, tt := range tests { + val, found := bag.Lookup(tt.path) + if found != tt.wantFound { + t.Errorf("Lookup(%q) found = %v, want %v", tt.path, found, tt.wantFound) + } + if found && val != tt.wantVal { + t.Errorf("Lookup(%q) val = %v, want %v", tt.path, val, tt.wantVal) + } + } +} + +func TestInputBagLookup_PointerStruct(t *testing.T) { + type Inner struct { + Val string `json:"val"` + } + type Outer struct { + Inner *Inner `json:"inner"` + } + + t.Run( + "non-nil pointer", func(t *testing.T) { + bag := NewInputBag(&Outer{Inner: &Inner{Val: "x"}}) + val, found := bag.Lookup("inner.val") + if !found || val != "x" { + t.Errorf("Lookup(inner.val) = %v, %v; want x, true", val, found) + } + }, + ) + + t.Run( + "nil outer pointer", func(t *testing.T) { + var o *Outer + bag := NewInputBag(o) + _, found := bag.Lookup("inner.val") + if found { + t.Error("Lookup on nil *struct should return false") + } + }, + ) + + t.Run( + "nil inner pointer", func(t *testing.T) { + bag := NewInputBag(&Outer{Inner: nil}) + _, found := bag.Lookup("inner.val") + if found { + t.Error("Lookup through nil inner pointer should return false") + } + }, + ) +} + +func TestInputBagLookup_EmbeddedStruct(t *testing.T) { + type Base struct { + ID int `json:"id"` + } + type Extended struct { + Base + Name string `json:"name"` + } + + bag := NewInputBag(Extended{Base: Base{ID: 42}, Name: "test"}) + val, found := bag.Lookup("id") + if !found || val != 42 { + t.Errorf("Lookup(id) on embedded struct = %v, %v; want 42, true", val, found) + } +} + +func TestInputBagLookup_NonStringKeyedMap(t *testing.T) { + input := map[int]string{1: "one", 2: "two"} + bag := NewInputBag(input) + _, found := bag.Lookup("1") + if found { + t.Error("non-string-keyed map should not be traversable") + } +} diff --git a/logical_rules.go b/logical_rules.go new file mode 100644 index 0000000..9ba017c --- /dev/null +++ b/logical_rules.go @@ -0,0 +1,131 @@ +package validation + +// Any returns a Rule that passes when at least one of the given rules passes. +// +// All rules are tried in order; the first pass short-circuits. If every rule fails, basicError{"any", "any validation failed"} is returned. +// The inner errors are not propagated. +// +// Fails if: +// - all supplied rules fail for the value +// +// Examples: +// +// validation.Any(validation.Email, validation.PhoneE164).Validate("user@example.com") // pass +// validation.Any(validation.Email, validation.PhoneE164).Validate("+14155552671") // pass +// validation.Any(validation.Email, validation.PhoneE164).Validate("notvalid") // fail +func Any(rules ...Rule) Rule { + return InputRuleFunc( + func(value any, input *InputBag) error { + for _, r := range rules { + if err := applyRule(r, value, input); err == nil { + return nil + } + } + + return basicError{"any", "any validation failed"} + }, + ) +} + +// Not returns a Rule that inverts the result of the given rule. +// +// Passes when the inner rule fails; fails (returning basicError{"not", "not validation failed"}) when the inner rule passes. +// +// Fails if: +// - the wrapped rule passes for the value +// +// Examples: +// +// validation.Not(validation.Email).Validate("not-an-email") // pass +// validation.Not(validation.Email).Validate("user@example.com") // fail +// validation.Not(validation.UUID).Validate("not-a-uuid") // pass +func Not(r Rule) Rule { + return InputRuleFunc( + func(value any, input *InputBag) error { + if err := applyRule(r, value, input); err != nil { + return nil + } + + return basicError{"not", "not validation failed"} + }, + ) +} + +// Unless returns an InputRule that applies the given rules only when the condition evaluates to false. +// +// It is the conditional complement of When: rules run when the condition is FALSE. +// The condition language is identical to RequiredIf (comparisons, &&, ||, !, exists(), len()). +// Returns RuleSyntaxError for a malformed condition. +// +// The errors from the inner rules are propagated directly (unlike Any/Not/Each which return sentinels). +// +// Examples: +// +// validation.Unless(`status == "approved"`, validation.MinLength(10)) +// validation.Unless(`exists(override)`, validation.Required) +func Unless(condition string, rules ...Rule) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + ok, err := evalCondition(condition, input) + if err != nil { + return RuleSyntaxError{Rule: "Unless", Err: err} + } + + if ok { + return nil + } + + for _, r := range rules { + if err := applyRule(r, value, input); err != nil { + return err + } + } + + return nil + }, + ) +} + +// When returns an InputRule that applies the given rules only when the condition evaluates to true. +// +// The condition language is identical to RequiredIf (comparisons, &&, ||, !, exists(), len()). +// Returns RuleSyntaxError for a malformed condition. +// +// The errors from the inner rules are propagated directly (unlike Any/Not/Each which return sentinels). +// +// Examples: +// +// validation.When(`plan == "paid"`, validation.Regex(`^[A-Z]{2}\d{9}$`), validation.MaxLength(12)) +// validation.When(`country == "US"`, validation.Regex(`^\d{10}$`)) +func When(condition string, rules ...Rule) InputRule { + return InputRuleFunc( + func(value any, input *InputBag) error { + ok, err := evalCondition(condition, input) + if err != nil { + return RuleSyntaxError{Rule: "When", Err: err} + } + + if !ok { + return nil + } + + for _, r := range rules { + if err := applyRule(r, value, input); err != nil { + return err + } + } + + return nil + }, + ) +} + +// applyRule dispatches a Rule, routing cross-field rules through ValidateWithInput when an InputBag is available. +// All combinators must call this instead of r.Validate directly so that wrapped InputRules receive the full input. +func applyRule(r Rule, value any, input *InputBag) error { + if ir, ok := r.(InputRule); ok && input != nil { + return ir.ValidateWithInput(value, input) + } + + return r.Validate(value) +} diff --git a/logical_rules_test.go b/logical_rules_test.go new file mode 100644 index 0000000..fe1cd85 --- /dev/null +++ b/logical_rules_test.go @@ -0,0 +1,211 @@ +package validation + +import ( + "errors" + "testing" +) + +func TestNot(t *testing.T) { + tests := []struct { + name string + rule Rule + value any + wantErr bool + }{ + {"not email: non-email passes", Not(Email), "not-an-email", false}, + {"not email: email fails", Not(Email), "user@example.com", true}, + {"not uuid: non-uuid passes", Not(UUID), "plain-string", false}, + {"not uuid: uuid fails", Not(UUID), "550e8400-e29b-41d4-a716-446655440000", true}, + {"not alpha: digit string passes", Not(Alpha), "123", false}, + {"not alpha: alpha string fails", Not(Alpha), "hello", true}, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + err := tt.rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Not.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "not" { + t.Errorf("Not.Validate(%v) wrong error: %v", tt.value, err) + } + }, + ) + } +} + +func TestNot_WithInputRule(t *testing.T) { + // Verify Not correctly dispatches InputRules (the cross-field dispatch keystone). + schema := New(). + Field("password", Required). + Field("new_password", Not(SameAs("password"))) + + t.Run( + "different values pass", func(t *testing.T) { + res, _ := schema.Validate( + map[string]any{ + "password": "old", + "new_password": "new", + }, + ) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) + + t.Run( + "same values fail", func(t *testing.T) { + res, _ := schema.Validate( + map[string]any{ + "password": "same", + "new_password": "same", + }, + ) + if !res.HasErrors() { + t.Error("expected errors, got none") + } + }, + ) +} + +func TestAny(t *testing.T) { + rule := Any(Email, PhoneE164) + tests := []struct { + name string + value any + wantErr bool + }{ + {"email passes", "user@example.com", false}, + {"phone passes", "+14155552671", false}, + {"neither fails", "notvalid", true}, + {"nil fails", nil, true}, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Any.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "any" { + t.Errorf("Any.Validate(%v) wrong error: %v", tt.value, err) + } + }, + ) + } +} + +func TestAny_WithInputRule(t *testing.T) { + // Verify Any dispatches InputRules correctly. + schema := New(). + Field("a", Required). + Field("b", Any(SameAs("a"), MinLength(10))) + + t.Run( + "same as a passes", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"a": "hello", "b": "hello"}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) + + t.Run( + "long enough passes", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"a": "hello", "b": "verylongvalue"}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) + + t.Run( + "neither passes fails", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"a": "hello", "b": "other"}) + if !res.HasErrors() { + t.Error("expected errors, got none") + } + }, + ) +} + +func TestWhen(t *testing.T) { + schema := New(). + Field("plan", Required). + Field("vat", When(`plan == "paid"`, MinLength(5))) + + t.Run( + "condition true: rule applied", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"plan": "paid", "vat": "AB"}) + if !res.HasErrors() { + t.Error("expected errors, got none") + } + }, + ) + + t.Run( + "condition true: rule passes", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"plan": "paid", "vat": "AB123"}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) + + t.Run( + "condition false: rule skipped", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"plan": "free", "vat": "X"}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) +} + +func TestWhen_InvalidCondition(t *testing.T) { + schema := New(). + Field("x", When(`!!!invalid`, MinLength(3))) + + _, err := schema.Validate(map[string]any{"x": "hi"}) + if err == nil { + t.Fatal("expected RuleSyntaxError from Validate, got nil") + } + var rse RuleSyntaxError + if !errors.As(err, &rse) { + t.Errorf("expected RuleSyntaxError, got %T", err) + } +} + +func TestUnless(t *testing.T) { + schema := New(). + Field("status", Required). + Field("reason", Unless(`status == "approved"`, MinLength(5))) + + t.Run( + "condition false: rule applied", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"status": "pending", "reason": "no"}) + if !res.HasErrors() { + t.Error("expected errors, got none") + } + }, + ) + + t.Run( + "condition false: rule passes", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"status": "pending", "reason": "under review"}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) + + t.Run( + "condition true: rule skipped", func(t *testing.T) { + res, _ := schema.Validate(map[string]any{"status": "approved", "reason": "no"}) + if res.HasErrors() { + t.Errorf("expected no errors, got %v", res.Errors()) + } + }, + ) +} diff --git a/network_rules.go b/network_rules.go new file mode 100644 index 0000000..f3fc5d4 --- /dev/null +++ b/network_rules.go @@ -0,0 +1,216 @@ +package validation + +import ( + "net" + "net/url" + "strings" +) + +// CIDR is a Rule that validates the value is a valid CIDR notation string (e.g. "192.168.0.0/24" or "2001:db8::/32"). +// +// Fails if: +// - value is not a string +// - the string cannot be parsed as a CIDR block +// +// Examples: +// +// validation.CIDR.Validate("192.168.0.0/24") // pass +// validation.CIDR.Validate("2001:db8::/32") // pass — IPv6 +// validation.CIDR.Validate("192.168.0.1") // fail — no prefix length +// validation.CIDR.Validate("not-cidr") // fail +var CIDR Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"cidr", "cidr validation failed"} + } + + if _, _, err := net.ParseCIDR(str); err != nil { + return basicError{"cidr", "cidr validation failed"} + } + + return nil + }, +) + +// IP is a Rule that validates the value is a valid IP address (v4 or v6). +// +// Fails if: +// - value is not a string +// - the string cannot be parsed as a valid IPv4 or IPv6 address +// +// Examples: +// +// validation.IP.Validate("192.168.1.1") // pass — IPv4 +// validation.IP.Validate("::1") // pass — IPv6 +// validation.IP.Validate("999.0.0.1") // fail +// validation.IP.Validate("not-an-ip") // fail +var IP Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || net.ParseIP(str) == nil { + return basicError{"ip", "ip validation failed"} + } + + return nil + }, +) + +// IPv4 is a Rule that validates the value is a valid IPv4 address. +// +// Fails if: +// - value is not a string +// - the string is not a valid IPv4 address (IPv6 addresses fail) +// +// Examples: +// +// validation.IPv4.Validate("192.168.1.1") // pass +// validation.IPv4.Validate("::1") // fail — IPv6 +// validation.IPv4.Validate("not-an-ip") // fail +var IPv4 Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"ipv4", "ipv4 validation failed"} + } + + ip := net.ParseIP(str) + if ip == nil || ip.To4() == nil { + return basicError{"ipv4", "ipv4 validation failed"} + } + + return nil + }, +) + +// IPv6 is a Rule that validates the value is a valid IPv6 address. +// +// Fails if: +// - value is not a string +// - the string is not a valid IPv6 address (IPv4 addresses fail) +// +// Examples: +// +// validation.IPv6.Validate("::1") // pass +// validation.IPv6.Validate("2001:db8::1") // pass +// validation.IPv6.Validate("192.168.1.1") // fail — IPv4 +// validation.IPv6.Validate("not-an-ip") // fail +var IPv6 Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"ipv6", "ipv6 validation failed"} + } + + ip := net.ParseIP(str) + if ip == nil || ip.To4() != nil { + return basicError{"ipv6", "ipv6 validation failed"} + } + + return nil + }, +) + +// MACAddress is a Rule that validates the value is a valid 6-byte MAC address. +// +// Accepted formats (via net.ParseMAC): 01:23:45:67:89:ab and 01-23-45-67-89-ab (case-insensitive). +// 8-byte EUI-64 addresses are rejected. +// +// Fails if: +// - value is not a string +// - the string is not a valid 6-byte MAC address +// +// Examples: +// +// validation.MACAddress.Validate("01:23:45:67:89:ab") // pass +// validation.MACAddress.Validate("01-23-45-67-89-AB") // pass +// validation.MACAddress.Validate("not-a-mac") // fail +var MACAddress Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"mac_address", "mac address validation failed"} + } + + hw, err := net.ParseMAC(str) + if err != nil || len(hw) != 6 { + return basicError{"mac_address", "mac address validation failed"} + } + + return nil + }, +) + +// URL is a Rule that validates that the value is a string that can be parsed as a valid absolute URL; +// scheme-less URLs are also accepted. +// +// The host must be a valid domain, IP address, or "localhost". +// +// Fails if: +// - value is not a string +// - value is an empty string +// - value has no resolvable host (e.g. "http://") +// - value contains characters that make it unparseable (e.g. unencoded spaces) +// +// Examples: +// +// validation.URL.Validate("https://example.com") // pass +// validation.URL.Validate("example.com/path") // pass — scheme inferred +// validation.URL.Validate("http://localhost:8080") // pass +// validation.URL.Validate("http://[::1]:8080/api") // pass — IPv6 +// validation.URL.Validate("not a url") // fail — unparseable +// validation.URL.Validate("http://") // fail — no host +var URL Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"url", "url validation failed"} + } + + if u, err := url.ParseRequestURI(str); err == nil && isValidURLHost(u.Host) { + return nil + } + if u, err := url.ParseRequestURI("http://" + str); err == nil && isValidURLHost(u.Host) { + return nil + } + + return basicError{"url", "url validation failed"} + }, +) + +func isValidURLHost(host string) bool { + if host == "" { + return false + } + + // Strip port if present (e.g. "example.com:8080" or "[::1]:8080") + if strings.HasPrefix(host, "[") { + // IPv6 in brackets: keep address, drop port. + if idx := strings.LastIndex(host, "]"); idx != -1 { + host = host[1:idx] + } + } else if h, _, ok := strings.Cut(host, ":"); ok { + host = h + } + + if net.ParseIP(host) != nil { + return true + } + + if host == "localhost" { + return true + } + + if !strings.Contains(host, ".") { + return false + } + + parts := strings.Split(host, ".") + for _, p := range parts { + if p == "" { + return false + } + } + + return true +} diff --git a/network_rules_test.go b/network_rules_test.go new file mode 100644 index 0000000..bf5d9f3 --- /dev/null +++ b/network_rules_test.go @@ -0,0 +1,174 @@ +package validation + +import ( + "testing" +) + +func TestURL(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"https://example.com", false}, + {"http://example.com", false}, + {"http://example.com/path?q=1", false}, + {"example.com", false}, + {"example.com/path", false}, + {"localhost", false}, + {"http://localhost:8080", false}, + {"http://127.0.0.1", false}, + {"example.com:8080", false}, + {"http://[::1]:8080", false}, + {"not a url", true}, + {"", true}, + {"http://", true}, + {42, true}, + {nil, true}, + } + for _, tt := range tests { + err := URL.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("URL.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "url" { + t.Errorf("URL.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestCIDR(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"192.168.0.0/24", false}, + {"10.0.0.0/8", false}, + {"0.0.0.0/0", false}, + {"2001:db8::/32", false}, + {"::/0", false}, + {"192.168.0.1", true}, // no prefix length + {"192.168.0.0/33", true}, // prefix too long + {"not-cidr", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := CIDR.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("CIDR.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "cidr" { + t.Errorf("CIDR.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestMACAddress(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"01:23:45:67:89:ab", false}, + {"01-23-45-67-89-AB", false}, + {"FF:FF:FF:FF:FF:FF", false}, + {"not-a-mac", true}, + {"01:23:45:67:89", true}, // too short + {"01:23:45:67:89:ab:cd:ef", true}, // 8-byte EUI-64 + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := MACAddress.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MACAddress.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "mac_address" { + t.Errorf("MACAddress.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestIP(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"192.168.1.1", false}, + {"10.0.0.1", false}, + {"0.0.0.0", false}, + {"255.255.255.255", false}, + {"::1", false}, + {"2001:db8::1", false}, + {"fe80::1", false}, + {"999.0.0.1", true}, + {"192.168.1", true}, + {"not-an-ip", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := IP.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("IP.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "ip" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestIPv4(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"192.168.1.1", false}, + {"0.0.0.0", false}, + {"255.255.255.255", false}, + {"::1", true}, // IPv6 fails + {"2001:db8::1", true}, // IPv6 fails + {"999.0.0.1", true}, + {"not-an-ip", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := IPv4.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("IPv4.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "ipv4" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestIPv6(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"::1", false}, + {"2001:db8::1", false}, + {"fe80::1", false}, + {"192.168.1.1", true}, // IPv4 fails + {"0.0.0.0", true}, // IPv4 fails + {"not-an-ip", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := IPv6.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("IPv6.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "ipv6" { + t.Errorf("wrong error type: %v", err) + } + } +} diff --git a/number_rules.go b/number_rules.go new file mode 100644 index 0000000..09decc2 --- /dev/null +++ b/number_rules.go @@ -0,0 +1,489 @@ +package validation + +import ( + "errors" + "math" + "reflect" + "strconv" +) + +type number interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 +} + +// Between returns a Rule that validates the value is between minV and maxV inclusive. +// +// The type parameter T must be instantiated explicitly because Go cannot infer it from the min/max arguments alone when +// the field value is typed as any. +// +// Fails if: +// - value cannot be type-asserted to T +// - value < min +// - value > max +// +// Examples: +// +// validation.Between[int](1, 100).Validate(50) // pass +// validation.Between[int](1, 100).Validate(1) // pass — inclusive +// validation.Between[int](1, 100).Validate(100) // pass — inclusive +// validation.Between[int](1, 100).Validate(0) // fail — below min +// validation.Between[int](1, 100).Validate(101) // fail — above max +// validation.Between[float64](0.0, 1.0).Validate(0.5) // pass +func Between[T number](minV, maxV T) Rule { + fn := func(value any) error { + v, ok := value.(T) + if !ok || v < minV || v > maxV { + return betweenError{Min: minV, Max: maxV} + } + + return nil + } + + return RuleFunc(fn) +} + +// GT returns a Rule that validates the value is strictly greater than v. +// +// Unlike Min (which uses >=), GT uses >, so equal values fail. +// +// Fails if: +// - value cannot be type-asserted to T +// - value <= v +// +// Examples: +// +// validation.GT[int](18).Validate(19) // pass +// validation.GT[int](18).Validate(18) // fail — equal +// validation.GT[int](18).Validate(17) // fail +func GT[T number](v T) Rule { + return RuleFunc( + func(value any) error { + actual, ok := value.(T) + if !ok || actual <= v { + return gtError{Value: v} + } + + return nil + }, + ) +} + +// GTE returns a Rule that validates the value is greater than or equal to v. +// +// GTE is semantically identical to Min; it exists as an explicit named alias. +// +// Fails if: +// - value cannot be type-asserted to T +// - value < v +// +// Examples: +// +// validation.GTE[int](18).Validate(18) // pass — equal +// validation.GTE[int](18).Validate(19) // pass +// validation.GTE[int](18).Validate(17) // fail +func GTE[T number](v T) Rule { + return RuleFunc( + func(value any) error { + actual, ok := value.(T) + if !ok || actual < v { + return gteError{Value: v} + } + + return nil + }, + ) +} + +// Integer is a Rule that validates the value is an integer type. +// +// Accepted kinds: int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64. +// Note: float64 (the default JSON number type) is rejected — use this rule when you need +// to assert the Go type is strictly integral. +// +// Fails if: +// - value is not one of the integer kinds above +// +// Passes if: +// - value is nil (absent field) +// +// Examples: +// +// validation.Integer.Validate(42) // pass +// validation.Integer.Validate(uint8(255)) // pass +// validation.Integer.Validate(3.14) // fail — float64 +// validation.Integer.Validate("42") // fail — string +var Integer Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + switch reflect.TypeOf(value).Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return nil + default: + return basicError{"integer", "integer validation failed"} + } + }, +) + +// Latitude is a Rule that validates the value is a valid latitude (−90 to 90 inclusive). +// +// Accepts any numeric type or float64 (the default JSON number type), nil passes. +// +// Fails if: +// - value is not a numeric type +// - value < -90 or value > 90 +// +// Examples: +// +// validation.Latitude.Validate(45.0) // pass +// validation.Latitude.Validate(-90.0) // pass — inclusive +// validation.Latitude.Validate(90.1) // fail +// validation.Latitude.Validate("45.0") // fail — string not accepted +var Latitude Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + fv, ok := condToFloat(value) + if !ok || fv < -90 || fv > 90 { + return basicError{"latitude", "latitude validation failed"} + } + + return nil + }, +) + +// Longitude is a Rule that validates the value is a valid longitude (−180 to 180 inclusive). +// +// Accepts any numeric type or float64 (the default JSON number type), nil passes. +// +// Fails if: +// - value is not a numeric type +// - value < -180 or value > 180 +// +// Examples: +// +// validation.Longitude.Validate(120.5) // pass +// validation.Longitude.Validate(-180.0) // pass — inclusive +// validation.Longitude.Validate(180.1) // fail +// validation.Longitude.Validate("120.5") // fail — string not accepted +var Longitude Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + fv, ok := condToFloat(value) + if !ok || fv < -180 || fv > 180 { + return basicError{"longitude", "longitude validation failed"} + } + + return nil + }, +) + +// LT returns a Rule that validates the value is strictly less than v. +// +// Unlike Max (which uses <=), LT uses <, so equal values fail. +// +// Fails if: +// - value cannot be type-asserted to T +// - value >= v +// +// Examples: +// +// validation.LT[int](100).Validate(99) // pass +// validation.LT[int](100).Validate(100) // fail — equal +// validation.LT[int](100).Validate(101) // fail +func LT[T number](v T) Rule { + return RuleFunc( + func(value any) error { + actual, ok := value.(T) + if !ok || actual >= v { + return ltError{Value: v} + } + + return nil + }, + ) +} + +// LTE returns a Rule that validates the value is less than or equal to v. +// +// LTE is semantically identical to Max; it exists as an explicit named alias. +// +// Fails if: +// - value cannot be type-asserted to T +// - value > v +// +// Examples: +// +// validation.LTE[int](100).Validate(100) // pass — equal +// validation.LTE[int](100).Validate(99) // pass +// validation.LTE[int](100).Validate(101) // fail +func LTE[T number](v T) Rule { + return RuleFunc( + func(value any) error { + actual, ok := value.(T) + if !ok || actual > v { + return lteError{Value: v} + } + + return nil + }, + ) +} + +// Max returns a Rule that validates the value is at most maxV. +// +// The type parameter T must be instantiated explicitly. +// +// Fails if: +// - value cannot be type-asserted to T +// - value > max +// +// Examples: +// +// validation.Max[int](100).Validate(100) // pass — equal to max +// validation.Max[int](100).Validate(50) // pass +// validation.Max[int](100).Validate(101) // fail — above max +// validation.Max[float64](1.0).Validate(1.1) // fail +func Max[T number](maxV T) Rule { + fn := func(value any) error { + v, ok := value.(T) + if !ok || v > maxV { + return maxError{Value: maxV} + } + + return nil + } + + return RuleFunc(fn) +} + +// Min returns a Rule that validates the value is at least minV. +// +// The type parameter T must be instantiated explicitly. +// +// Fails if: +// - value cannot be type-asserted to T +// - value < min +// +// Examples: +// +// validation.Min[int](18).Validate(18) // pass — equal to min +// validation.Min[int](18).Validate(21) // pass +// validation.Min[int](18).Validate(17) // fail — below min +// validation.Min[float64](0.5).Validate(0.4) // fail +func Min[T number](minV T) Rule { + fn := func(value any) error { + v, ok := value.(T) + if !ok || v < minV { + return minError{Value: minV} + } + + return nil + } + + return RuleFunc(fn) +} + +// MultipleOf returns a Rule that validates the value is a multiple of n. +// +// Accepts any numeric type; comparison is done in float64, n must not be zero. +// If n is zero, Schema.Validate returns a RuleSyntaxError. +// +// Fails if: +// - value is nil +// - value is not a numeric type +// - value is not evenly divisible by n +// +// Examples: +// +// validation.MultipleOf[int](3).Validate(9) // pass +// validation.MultipleOf[int](3).Validate(float64(9)) // pass — JSON number accepted +// validation.MultipleOf[int](3).Validate(8) // fail +func MultipleOf[T number](n T) Rule { + return RuleFunc( + func(value any) error { + if float64(n) == 0 { + return RuleSyntaxError{Rule: "MultipleOf", Err: errors.New("divisor must not be zero")} + } + + if value == nil { + return multipleOfError{Value: n} + } + + fv, ok := condToFloat(value) + if !ok { + return multipleOfError{Value: n} + } + + if math.Mod(fv, float64(n)) != 0 { + return multipleOfError{Value: n} + } + + return nil + }, + ) +} + +// Negative is a Rule that validates the value is strictly less than zero. +// +// Accepts any numeric type, nil passes. +// +// Fails if: +// - value is not a numeric type +// - value >= 0 +// +// Examples: +// +// validation.Negative.Validate(-1) // pass +// validation.Negative.Validate(-0.5) // pass +// validation.Negative.Validate(0) // fail — zero is not negative +// validation.Negative.Validate(1) // fail +var Negative Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + fv, ok := condToFloat(value) + if !ok || fv >= 0 { + return basicError{"negative", "negative validation failed"} + } + + return nil + }, +) + +// NonNegative is a Rule that validates the value is greater than or equal to zero. +// +// Accepts any numeric type, nil passes. +// +// Fails if: +// - value is not a numeric type +// - value < 0 +// +// Examples: +// +// validation.NonNegative.Validate(0) // pass — zero is non-negative +// validation.NonNegative.Validate(5) // pass +// validation.NonNegative.Validate(-1) // fail +// validation.NonNegative.Validate(-0.1) // fail +var NonNegative Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + fv, ok := condToFloat(value) + if !ok || fv < 0 { + return basicError{"non_negative", "non negative validation failed"} + } + + return nil + }, +) + +// Numeric is a Rule that validate the value is a number, or it can be converted to a number. +// +// Accepted types: int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, complex64, +// complex128, and any string whose content is a valid decimal number. +// +// Fails if: +// - value is nil or a boolean +// - value is a string that cannot be parsed as float64 (e.g. "abc", "1.2.3") +// - value is any other non-numeric type (slice, map, struct, etc.) +// +// Examples: +// +// validation.Numeric.Validate(42) // pass +// validation.Numeric.Validate(3.14) // pass +// validation.Numeric.Validate("99.5") // pass — parseable string +// validation.Numeric.Validate("abc") // fail — not a number +// validation.Numeric.Validate(true) // fail — boolean not accepted +var Numeric Rule = RuleFunc( + func(value any) error { + switch v := value.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64, + complex64, complex128: + return nil + case string: + _, err := strconv.ParseFloat(v, 64) + if err != nil { + return basicError{"numeric", "numeric validation failed"} + } + + return nil + default: + return basicError{"numeric", "numeric validation failed"} + } + }, +) + +// Port is a Rule that validates the value is a valid TCP/UDP port number (1–65535). +// +// Accepts any numeric type. Fractional values (e.g. float64(80.5)) fail, nil passes. +// +// Fails if: +// - value is not a numeric type +// - value is fractional +// - value < 1 or value > 65535 +// +// Examples: +// +// validation.Port.Validate(80) // pass +// validation.Port.Validate(float64(8080)) // pass — JSON number accepted +// validation.Port.Validate(0) // fail — port 0 is reserved +// validation.Port.Validate(65536) // fail +// validation.Port.Validate(80.5) // fail — fractional +var Port Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + fv, ok := condToFloat(value) + if !ok || fv != math.Trunc(fv) || fv < 1 || fv > 65535 { + return basicError{"port", "port validation failed"} + } + + return nil + }, +) + +// Positive is a Rule that validates the value is strictly greater than zero. +// +// Accepts any numeric type, nil passes. +// +// Fails if: +// - value is not a numeric type +// - value <= 0 +// +// Examples: +// +// validation.Positive.Validate(1) // pass +// validation.Positive.Validate(0.1) // pass +// validation.Positive.Validate(0) // fail — zero is not positive +// validation.Positive.Validate(-1) // fail +var Positive Rule = RuleFunc( + func(value any) error { + if value == nil { + return nil + } + + fv, ok := condToFloat(value) + if !ok || fv <= 0 { + return basicError{"positive", "positive validation failed"} + } + + return nil + }, +) diff --git a/number_rules_test.go b/number_rules_test.go new file mode 100644 index 0000000..136010d --- /dev/null +++ b/number_rules_test.go @@ -0,0 +1,475 @@ +package validation + +import ( + "errors" + "testing" +) + +func TestNumeric(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {int(1), false}, + {int8(2), false}, + {int16(3), false}, + {int32(4), false}, + {int64(5), false}, + {uint(6), false}, + {uint8(7), false}, + {uint16(8), false}, + {uint32(9), false}, + {uint64(10), false}, + {float32(1.5), false}, + {float64(2.5), false}, + {complex64(1 + 2i), false}, + {complex128(3 + 4i), false}, + {"3.14", false}, + {"42", false}, + {"not a number", true}, + {"", true}, + {true, true}, + {nil, true}, + {[]int{1}, true}, + } + for _, tt := range tests { + err := Numeric.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Numeric.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "numeric" { + t.Errorf("Numeric.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestBetween(t *testing.T) { + t.Run( + "int", func(t *testing.T) { + rule := Between[int](1, 10) + tests := []struct { + value any + wantErr bool + }{ + {1, false}, + {5, false}, + {10, false}, + {0, true}, + {11, true}, + {float64(5), true}, // wrong type + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Between[int](1,10).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "between" { + t.Errorf("wrong error type: %v", err) + } + } + }, + ) + + t.Run( + "float64", func(t *testing.T) { + rule := Between[float64](0.0, 1.0) + tests := []struct { + value any + wantErr bool + }{ + {0.0, false}, + {0.5, false}, + {1.0, false}, + {-0.1, true}, + {1.1, true}, + {int(1), true}, // wrong type + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Between[float64](0,1).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } + }, + ) +} + +func TestMin(t *testing.T) { + t.Run( + "int", func(t *testing.T) { + rule := Min[int](18) + tests := []struct { + value any + wantErr bool + }{ + {18, false}, + {21, false}, + {17, true}, + {0, true}, + {float64(20), true}, + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Min[int](18).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "min" { + t.Errorf("wrong error type: %v", err) + } + } + }, + ) +} + +func TestMax(t *testing.T) { + t.Run( + "int", func(t *testing.T) { + rule := Max[int](100) + tests := []struct { + value any + wantErr bool + }{ + {100, false}, + {50, false}, + {101, true}, + {float64(50), true}, + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Max[int](100).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "max" { + t.Errorf("wrong error type: %v", err) + } + } + }, + ) +} + +func TestGT(t *testing.T) { + rule := GT[int](18) + tests := []struct { + value any + wantErr bool + }{ + {19, false}, + {100, false}, + {18, true}, // equal fails + {17, true}, + {float64(19), true}, + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("GT[int](18).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "gt" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestGTE(t *testing.T) { + rule := GTE[int](18) + tests := []struct { + value any + wantErr bool + }{ + {18, false}, // equal passes + {19, false}, + {17, true}, + {float64(18), true}, + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("GTE[int](18).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "gte" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestLT(t *testing.T) { + rule := LT[int](100) + tests := []struct { + value any + wantErr bool + }{ + {99, false}, + {0, false}, + {100, true}, // equal fails + {101, true}, + {float64(99), true}, + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("LT[int](100).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "lt" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestLTE(t *testing.T) { + rule := LTE[int](100) + tests := []struct { + value any + wantErr bool + }{ + {100, false}, // equal passes + {99, false}, + {101, true}, + {float64(100), true}, + {nil, true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("LTE[int](100).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "lte" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestInteger(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {42, false}, + {int8(1), false}, + {int16(2), false}, + {int32(3), false}, + {int64(4), false}, + {uint(5), false}, + {uint8(6), false}, + {uint16(7), false}, + {uint32(8), false}, + {uint64(9), false}, + {nil, false}, // absent field passes + {3.14, true}, // float64 + {"42", true}, // string + {true, true}, // bool + {float32(1.0), true}, + } + for _, tt := range tests { + err := Integer.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Integer.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "integer" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestPositive(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {1, false}, + {0.1, false}, + {float64(5), false}, + {uint(3), false}, + {0, true}, + {-1, true}, + {-0.1, true}, + {nil, false}, + {"5", true}, + {true, true}, + } + for _, tt := range tests { + err := Positive.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Positive.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "positive" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestNegative(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {-1, false}, + {-0.1, false}, + {float64(-5), false}, + {0, true}, + {1, true}, + {uint(0), true}, + {nil, false}, + {"5", true}, + } + for _, tt := range tests { + err := Negative.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Negative.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "negative" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestNonNegative(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {0, false}, + {1, false}, + {0.0, false}, + {float64(5), false}, + {uint(0), false}, + {-1, true}, + {-0.1, true}, + {nil, false}, + {"0", true}, + } + for _, tt := range tests { + err := NonNegative.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("NonNegative.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "non_negative" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestMultipleOf(t *testing.T) { + t.Run( + "int divisor", func(t *testing.T) { + rule := MultipleOf[int](3) + tests := []struct { + value any + wantErr bool + }{ + {9, false}, + {0, false}, + {-6, false}, + {float64(9), false}, // JSON number + {8, true}, + {nil, true}, + {"9", true}, + } + for _, tt := range tests { + err := rule.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MultipleOf[int](3).Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } + }, + ) + + t.Run( + "zero divisor returns syntax error", func(t *testing.T) { + rule := MultipleOf[int](0) + err := rule.Validate(9) + if err == nil { + t.Error("expected error for zero divisor") + } + var rse RuleSyntaxError + if !errors.As(err, &rse) { + t.Errorf("expected RuleSyntaxError, got %T", err) + } + }, + ) +} + +func TestPort(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {80, false}, + {443, false}, + {1, false}, + {65535, false}, + {float64(8080), false}, // JSON number + {0, true}, + {65536, true}, + {-1, true}, + {80.5, true}, // fractional + {nil, false}, + {"80", true}, + } + for _, tt := range tests { + err := Port.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Port.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "port" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestLatitude(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {0.0, false}, + {45.0, false}, + {-90.0, false}, + {90.0, false}, + {float64(45), false}, + {90.1, true}, + {-90.1, true}, + {nil, false}, + {"45", true}, + } + for _, tt := range tests { + err := Latitude.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Latitude.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "latitude" { + t.Errorf("wrong error type: %v", err) + } + } +} + +func TestLongitude(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {0.0, false}, + {120.5, false}, + {-180.0, false}, + {180.0, false}, + {float64(120), false}, + {180.1, true}, + {-180.1, true}, + {nil, false}, + {"120", true}, + } + for _, tt := range tests { + err := Longitude.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Longitude.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "longitude" { + t.Errorf("wrong error type: %v", err) + } + } +} diff --git a/result.go b/result.go deleted file mode 100644 index 8940082..0000000 --- a/result.go +++ /dev/null @@ -1,24 +0,0 @@ -package validation - -import "github.com/behzadsh/go.validator/bag" - -// Result is a struct consist of validation errors. -type Result struct { - Errors bag.ErrorBag -} - -// NewResult generate a new result object. -func NewResult() Result { - return Result{ - Errors: make(bag.ErrorBag), - } -} - -// Failed returns true if there is an error. -func (r Result) Failed() bool { - return !r.Errors.IsEmpty() -} - -func (r Result) addError(selector string, errorMsg ...string) { - r.Errors.Add(selector, errorMsg...) -} diff --git a/result_test.go b/result_test.go deleted file mode 100644 index f3a5f87..0000000 --- a/result_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewResult(t *testing.T) { - res := NewResult() - - assert.NotNil(t, res) - assert.Empty(t, res.Errors) -} - -func TestResult_Failed(t *testing.T) { - res := NewResult() - assert.False(t, res.Failed()) - - res.addError("something", "error") - assert.True(t, res.Failed()) -} diff --git a/rule_indicator.go b/rule_indicator.go deleted file mode 100644 index af1c62e..0000000 --- a/rule_indicator.go +++ /dev/null @@ -1,51 +0,0 @@ -package validation - -import ( - "fmt" - "strings" - - "github.com/spf13/cast" - "github.com/thoas/go-funk" - - "github.com/behzadsh/go.validator/rules" - "github.com/behzadsh/go.validator/translation" -) - -type ruleIndicator string - -func (r ruleIndicator) load(locale string) rules.Rule { - name, params := r.parseRuleParams() - rule, ok := registry[name] - if !ok || rule == nil { - panic(fmt.Errorf("rule %s is not registered", name)) - } - - if ruleWithParams, ok := rule.(rules.RuleWithParams); ok { - paramNum := len(params) - minRequiredParams := ruleWithParams.MinRequiredParams() - - if paramNum < minRequiredParams { - panic(fmt.Errorf("rule %s need at least %d parameter, got %d", name, minRequiredParams, paramNum)) - } - - ruleWithParams.AddParams(params) - } - - if translatableRule, ok := rule.(translation.TranslatableRule); ok { - translatableRule.AddLocale(locale) - translatableRule.AddTranslationFunction(translation.GetDefaultTranslatorFunc()) - } - - return rule -} - -func (r ruleIndicator) parseRuleParams() (string, []string) { - parts := strings.SplitN(string(r), ":", 2) - if len(parts) == 1 { - return parts[0], nil - } - - return parts[0], cast.ToStringSlice(funk.Map(strings.Split(parts[1], ","), func(s string) string { - return strings.TrimSpace(s) - })) -} diff --git a/rules.md b/rules.md deleted file mode 100644 index 2e36bc1..0000000 --- a/rules.md +++ /dev/null @@ -1,1445 +0,0 @@ -# Available Rules - -## Index - -
-Click to expand - -- [After](#after) -- [After Or Equal](#afterOrEqual) -- [Alpha](#alpha) -- [Alpha Dash](#alphaDash) -- [Alpha Num](#alphaNum) -- [Alpha Space](#alphaSpace) -- [Array](#array) -- [Before](#before) -- [Before Or Equal](#beforeOrEqual) -- [Between](#between) -- [Boolean](#boolean) -- [Datetime](#datetime) -- [Datetime Format](#datetimeFormat) -- [DateTime After](#dateTimeAfter) -- [DateTime Before](#dateTimeBefore) -- [DateTime Between](#dateTimeBetween) -- [Different](#different) -- [Digits](#digits) -- [Digits Between](#digitsBetween) -- [Distinct](#distinct) -- [Email](#email) -- [Ends With](#endsWith) -- [Greater Than](#gt) -- [Greater Than or Equal](#gte) -- [In](#in) -- [InArrayField](#inArrayField) -- [Integer](#integer) -- [IP](#ip) -- [IPV4](#ipv4) -- [IPV6](#ipv6) -- [Length](#length) -- [Lowercase](#lowercase) -- [Less Than](#lt) -- [Less Than or Equal](#lte) -- [Max](#max) -- [Max Digits](#maxDigits) -- [Max Length](#maxLength) -- [Min](#min) -- [Min Digits](#minDigits) -- [Min Length](#minLength) -- [Not Equal](#neq) -- [Not Empty](#notEmpty) -- [Not In](#notIn) -- [Not Regex](#notRegex) -- [Numeric](#numeric) -- [Regex](#regex) -- [Required](#required) -- [Required If](#requiredIf) -- [Required Unless](#requiredUnless) -- [Required With](#requiredWith) -- [Required With All](#requiredWithAll) -- [Required Without](#requiredWithout) -- [Required Without All](#requiredWithoutAll) -- [Same As](#sameAs) -- [Starts With](#startsWith) -- [String](#string) -- [Timezone](#timezone) -- [Uppercase](#uppercase) -- [URL](#url) -- [UUID](#uuid) - -
- - -## After: `after:otherField[,timeZone]` -This rule checks the field under validation is a datetime value after the given datetime. The `after` rule accepts two parameters, `otherField` and `timeZone`. The `otherField` is a mandatory parameter that specifies the field to compare the current field value. - -> Note that when using this rule, the field under validation must pass the `datetime` rule, and the `otherField` must be present and also pass the `datetime` rule. - -```go -rulesMap := validation.RulesMap{ - "end": {"after:start"} -} -``` - -In this example, then `end` and `start` must pass the `datetime` rule, and also `start` must be present. - -> The `after` rule does not imply the `required` rule on the field under validation. - -### Timezone parameter -If you want to compare the time values in a specified time zone, you can pass your desired time zone string as the second parameter. - -```go -rulesMap := validation.RulesMap{ - "end": {"after:start,America/New_York"} -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|------------------|-------------------| -| validation.after | field, otherField | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | -| OtherField | The other field that the current field is compare with. | - - -## After Or Equal: `afterOrEqual:otherField[,timeZone]` -This rule checks the field under validation is a datetime value after or equal to the given datetime. The `afterOrEqual` rule accepts two parameters, `otherField` and `timeZone`. The `otherField` is a mandatory parameter that specifies the field to compare the current field value. - -> Note that when using this rule, the field under validation must pass the `datetime` rule, and the `otherField` must be present and also pass the `datetime` rule. - -```go -rulesMap := validation.RulesMap{ - "end": {"afterOrEqual:start"} -} -``` - -In this example, then `end` and `start` must pass the `datetime` rule, and also `start` must be present. - -> The `afterOrEqual` rule does not imply the `required` rule on the field under validation. - -### Timezone parameter -If you want to compare the time values in a specified time zone, you can pass your desired time zone string as the second parameter. - -```go -rulesMap := validation.RulesMap{ - "end": {"afterOrEqual:start,America/New_York"} -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|---------------------------|-------------------| -| validation.after_or_equal | field, otherField | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | -| OtherField | The other field that the current field is compare with. | - - -## Alpha: `alpha` -This rule checks the field under validation is entirely alphabetic characters. - -```go -rulesMap := validation.RulesMap{ - "name": {"alpha"} -} -``` - -### Translation - -| Key | Params | -|------------------|--------| -| validation.alpha | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Alpha Dash: `alphaDash` -This rule checks the field under validation has alphabetic characters as well as dashes and underscores. - -```go -rulesMap := validation.RulesMap{ - "filename": {"alphaDash"} -} -``` - -### Translation - -| Key | Params | -|-----------------------|--------| -| validation.alpha_dash | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Alpha Num: `alphaNum` -This rule checks the field under validation has alphanumeric characters. - -```go -rulesMap := validation.RulesMap{ - "title": {"alphaNum"} -} -``` - -### Translation - -| Key | Params | -|----------------------|--------| -| validation.alpha_num | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Alpha Space: `alphaSpace` -This rule checks the field under validation has alphabetic characters as well as spaces. - -```go -rulesMap := validation.RulesMap{ - "title": {"alphaSpace"} -} -``` - -### Translation - -| Key | Params | -|------------------------|--------| -| validation.alpha_space | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Array: `array` -This rule checks the field under validation is an array or slice. - -```go -rulesMap := validation.RulesMap{ - "title": {"array"} -} -``` - -### Translation - -| Key | Params | -|------------------|--------| -| validation.array | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Before: `before:otherField[,timeZone]` -This rule checks the field under validation is a datetime value before the given datetime. The `before` rule accepts two parameters, `otherField` and `timeZone`. The `otherField` is a mandatory parameter that specifies the field to compare the current field value. - -> Note that when using this rule, the field under validation must pass the `datetime` rule, and the `otherField` must be present and also pass the `datetime` rule. - -```go -rulesMap := validation.RulesMap{ - "start": {"before:end"} -} -``` - -In this example, then `start` and `end` must pass the `datetime` rule, and also `end` must be present. - -> The `before` rule does not imply the `required` rule on the field under validation. - -### Timezone parameter -If you want to compare the time values in a specified time zone, you can pass your desired time zone string as the second parameter. - -```go -rulesMap := validation.RulesMap{ - "end": {"before:start,America/New_York"} -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|-------------------|-------------------| -| validation.before | field, otherField | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | -| OtherField | The other field that the current field is compare with. | - - -## Before Or Equal: `beforeOrEqual:otherField[,timeZone]` -This rule checks the field under validation is a datetime value before or equal to the given datetime. The `beforeOrEqual` rule accepts two parameters, `otherField` and `timeZone`. The `otherField` is a mandatory parameter that specifies the field to compare the current field value. - -> Note that when using this rule, the field under validation must pass the `datetime` rule, and the `otherField` must be present and also pass the `datetime` rule. - -```go -rulesMap := validation.RulesMap{ - "start": {"beforeOrEqual:end"} -} -``` - -In this example, then `start` and `end` must pass the `datetime` rule, and also `end` must be present. - -> The `beforeOrEqual` rule does not imply the `required` rule on the field under validation. - -### Timezone parameter -If you want to compare the time values in a specified time zone, you can pass your desired time zone string as the second parameter. - -```go -rulesMap := validation.RulesMap{ - "start": {"beforeOrEqual:end,America/New_York"} -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|----------------------------|-------------------| -| validation.before_or_equal | field, otherField | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | -| OtherField | The other field that the current field is compare with. | - - -## Between: `between:min,max` -This rule acts a little differently based on the type of field value. - - * If the value is numeric, the rule will check the value is between the given min and max. - * If the value is a string, slice, array, or map, the rule will check the length of the value is between the given min and max. - * Otherwise, the rule checks nothing. - -```go -rulesMap := validation.RulesMap{ - "age": {"between:18,30"}, // assume age is an integer value - "score": {"between:8.5,10"}, // assume score is a float value - "title": {"between:5,50"}, // assume title is a string value - "skills": {"between:2,5"}, // assume skills is a slice of strings -} -``` - -> The `between` rule only checks the condition when the field under validation is present. - -### Translation - -| Key | Params | -|--------------------|-----------------| -| validation.between | field, min, max | - -| Param name | Description | -|------------|------------------------------------| -| field | The field under validation. | -| min | The minimum value set in the rule. | -| max | The maximum value set in the rule. | - - -## Boolean: `boolean` -This rule checks the field under validation has a boolean value. - -```go -rulesMap := validation.RulesMap{ - "accept": {"boolean"} -} -``` - -### Translation - -| Key | Params | -|--------------------|--------| -| validation.boolean | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Datetime: `datetime` -This rule checks the field under validation is a valid datetime string. - -```go -rulesMap := validation.RulesMap{ - "start": {"datetime"} -} -``` - -### Translation - -| Key | Params | -|---------------------|--------| -| validation.datetime | field | - -| Param name | Description | -|------------|---------------------------------------------------------| -| field | The field under validation. | - - -## Datetime Format: `datetimeFormat:format` -This rule checks the field under validation matches the given datetime layout format. - -```go -rulesMap := validation.RulesMap{ - "start": {"datetimeFormat:2006-01-02T15:04:05Z07:00"} -} -``` - -> The `datetimeFormat` rule only checks the condition when the field under validation is present. - -### Translation - -| Key | Params | -|----------------------------|---------------| -| validation.datetime_format | field, format | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| format | The datetime layout format. | - -## DateTime After: `dateTimeAfter:value[,timeZone]` -This rule checks the field under validation is a datetime after the given datetime value. - -```go -rulesMap := validation.RulesMap{ - "start": {"dateTimeAfter:2022-01-01,America/New_York"}, -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|---------------------------|--------------| -| validation.datetime_after | field, value | - -| Param name | Description | -|------------|------------------------------------| -| field | The field under validation. | -| value | The threshold datetime string. | - -## DateTime Before: `dateTimeBefore:value[,timeZone]` -This rule checks the field under validation is a datetime before the given datetime value. - -```go -rulesMap := validation.RulesMap{ - "end": {"dateTimeBefore:2022-01-01,America/New_York"}, -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|----------------------------|--------------| -| validation.datetime_before | field, value | - -| Param name | Description | -|------------|------------------------------------| -| field | The field under validation. | -| value | The threshold datetime string. | - -## DateTime Between: `dateTimeBetween:min,max[,timeZone]` -This rule checks the field under validation is a datetime value between the given min and max. - -```go -rulesMap := validation.RulesMap{ - "date": {"dateTimeBetween:2022-01-01,2022-02-01,UTC"}, -} -``` - -> The default value for the time zone parameter is **UTC**. - -### Translation - -| Key | Params | -|------------------------------|-----------------| -| validation.datetime_between | field, min, max | - -| Param name | Description | -|------------|------------------------------------| -| field | The field under validation. | -| min | The minimum datetime string. | -| max | The maximum datetime string. | - - -## Different: `different:otherField` -This rule checks the field under validation has a different value than the given field. - -```go -rulesMap := validation.RulesMap{ - "newPassword": {"different:oldPassword"} -} -``` - -### Translation - -| Key | Params | -|----------------------|-------------------| -| validation.different | field, otherField | - -| Param name | Description | -|------------|----------------------------------------------------| -| field | The field under validation. | -| otherField | The other field that current field is compared to. | - - -## Digits: `digits:count` -This rule checks the field under validation is all digits and has exact digits as given count. - -```go -rulesMap := validation.RulesMap{ - "code": {"digits:6"} -} -``` - -### Translation - -| Key | Params | -|-------------------|--------------------| -| validation.digits | field, digitsCount | - -| Param name | Description | -|-------------|-----------------------------------| -| field | The field under validation. | -| digitsCount | The number of digits set in rule. | - - -## Digits Between: `digitsBetween:min,max` -This rule checks the field under validation is all digits and has digits between the given min and max. - -```go -rulesMap := validation.RulesMap{ - "code": {"digitsBetween:4,6"} -} -``` - -### Translation - -| Key | Params | -|---------------------------|-----------------| -| validation.digits_between | field, min, max | - -| Param name | Description | -|------------|-------------------------------------------| -| field | The field under validation. | -| min | The minimum digits count set in the rule. | -| max | The maximum digits count set in the rule. | - - -## Email: `email[:mx]` -This rule checks the field under validation has a valid email format. - -```go -rulesMap := validation.RulesMap{ - "email": {"email"} -} -``` - -There is also an optional parameter called `mx` which doing extra check on email address and checks the -MX record in given email host dns records. - -```go -rulesMap := validation.RulesMap{ - "email": {"email:mx"} -} -``` - -### Translation - -| Key | Params | -|------------------|--------| -| validation.email | field | - -| Param name | Description | -|------------|-------------------------------------------| -| field | The field under validation. | - -## Distinct: `distinct` -This rule checks the field under validation is an array/slice with unique elements. - -```go -rulesMap := validation.RulesMap{ - "tags": {"distinct"}, -} -``` - -### Translation - -| Key | Params | -|---------------------|--------| -| validation.distinct | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Ends With: `ends_with:suffix` -This rules checks the field under validation ends with the given suffix string. - -```go -rulesMap := validation.RulesMap{ - "className": {"endsWith:Model"} -} -``` - -> The `suffix` parameter is case-sensitive. - -### Translation - -| Key | Params | -|----------------------|--------------| -| validation.ends_with | field, value | - -| Param name | Description | -|------------|-------------------------------| -| field | The field under validation. | -| value | The suffix value set in rule. | - - -## Greater Than: `gt:value` -This rule acts a little differently based on the type of field value. - -* If the value is numeric, the rule will check the value is greater than given value. -* If the value is a string, slice, array, or map, the rule will check the length of the value is greater than given value. -* Otherwise, the rule checks nothing. - -```go -rulesMap := validation.RulesMap{ - "age": {"gt:18"}, // assume age is an integer value - "score": {"gt:8.5"}, // assume score is a float value - "title": {"gt:5"}, // assume title is a string value - "skills": {"gt:2"}, // assume skills is a slice of strings -} -``` - -### Translation - -| Key | Params | -|---------------|--------------| -| validation.gt | field, value | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| value | The min value set in rule. | - - -## Greater Than or Equal: `gte:value` -This rule acts a little differently based on the type of field value. - -* If the value is numeric, the rule will check the value is greater than or equal to given value. -* If the value is a string, slice, array, or map, the rule will check the length of the value is greater than or equal to given value. -* Otherwise, the rule checks nothing. - -```go -rulesMap := validation.RulesMap{ - "age": {"gte:18"}, // assume age is an integer value - "score": {"gte:8.5"}, // assume score is a float value - "title": {"gte:5"}, // assume title is a string value - "skills": {"gte:2"}, // assume skills is a slice of strings -} -``` - -### Translation - -| Key | Params | -|----------------|--------------| -| validation.gte | field, value | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| value | The min value set in rule. | - - -## In: `in:value1,value2[,value3,...]` -This rule check the field under validation exists in one of the given options. - -> This rule is case-insensitive. - -```go -rulesMap := validation.RulesMap{ - "currency": {"in:usd,eur,gbp"}, -} -``` - -### Translation - -| Key | Params | -|---------------|--------| -| validation.in | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - -## In Array Field: `inArrayField:otherField` -This rule checks the field under validation exists in another array/slice field. - -```go -rulesMap := validation.RulesMap{ - "choice": {"inArrayField:options"}, -} -``` - -### Translation - -| Key | Params | -|---------------------|-------------------| -| validation.in | field | -| Validation.required | otherField | - -| Param name | Description | -|------------|---------------------------------------------| -| field | The field under validation. | -| otherField | The other field that contains valid options. | - - -## Integer: `integer` -This rule checks the field under validation is an integer. - -```go -rulesMap := validation.RulesMap{ - "age": {"integer"}, -} -``` - -### Translation - -| Key | Params | -|--------------------|--------| -| validation.integer | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - -## IP: `ip` -This rule checks the field under validation is a valid IP address (v4 or v6). - -```go -rulesMap := validation.RulesMap{ - "addr": {"ip"}, -} -``` - -### Translation - -| Key | Params | -|----------------|--------| -| validation.ip | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - -## IPV4: `ipv4` -This rule checks the field under validation is a valid IPv4 address. - -```go -rulesMap := validation.RulesMap{ - "addr": {"ipv4"}, -} -``` - -### Translation - -| Key | Params | -|------------------|--------| -| validation.ipv4 | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - -## IPV6: `ipv6` -This rule checks the field under validation is a valid IPv6 address. - -```go -rulesMap := validation.RulesMap{ - "addr": {"ipv6"}, -} -``` - -### Translation - -| Key | Params | -|------------------|--------| -| validation.ipv6 | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Length: `length:value` -This rule checks the field under validation length is as the given value. - -```go -rulesMap := validation.RulesMap{ - "skills": {"length:3"}, -} -``` - -> The `length` rule only apply on strings, arrays, slices and maps. - -### Translation - -| Key | Params | -|-------------------|--------------| -| validation.length | field, value | - -| Param name | Description | -|------------|------------------------------| -| field | The field under validation. | -| value | The given value in the rule. | - - - -## Lowercase: `lowercase` -This rule checks the field under validation is all lowercase. - -```go -rulesMap := validation.RulesMap{ - "name": {"lowercase"}, -} -``` - -### Translation - -| Key | Params | -|----------------------|--------| -| validation.lowercase | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Less Than: `lt:value` -This rule acts a little differently based on the type of field value. - -* If the value is numeric, the rule will check the value is less than given value. -* If the value is a string, slice, array, or map, the rule will check the length of the value is less than given value. -* Otherwise, the rule checks nothing. - -```go -rulesMap := validation.RulesMap{ - "age": {"lt:18"}, // assume age is an integer value - "score": {"lt:8.5"}, // assume score is a float value - "title": {"lt:5"}, // assume title is a string value - "skills": {"lt:2"}, // assume skills is a slice of strings -} -``` - -### Translation - -| Key | Params | -|---------------|--------------| -| validation.lt | field, value | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| value | The max value set in rule. | - - -## Less Than or Equal: `lte:value` -This rule acts a little differently based on the type of field value. - -* If the value is numeric, the rule will check the value is less than or equal to given value. -* If the value is a string, slice, array, or map, the rule will check the length of the value is less than or equal to given value. -* Otherwise, the rule checks nothing. - -```go -rulesMap := validation.RulesMap{ - "age": {"lte:18"}, // assume age is an integer value - "score": {"lte:8.5"}, // assume score is a float value - "title": {"lte:5"}, // assume title is a string value - "skills": {"lte:2"}, // assume skills is a slice of strings -} -``` - -### Translation - -| Key | Params | -|----------------|--------------| -| validation.lte | field, value | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| value | The max value set in rule. | - - -## Max: `max:value` -This rule checks the field under validation value be less than given value. - -```go -rulesMap := validation.RulesMap{ - "age": {"max:30"} -} -``` - -### Translation - -| Key | Params | -|----------------|--------------| -| validation.max | field, value | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| value | The max value set in rule. | - - -## Max Digits: `maxDigits:value` -This rule checks the field under validation has length less than given max digits. - -```go -rulesMap := validation.RulesMap{ - "code": {"maxDigits:5"} -} -``` - -### Translation - -| Key | Params | -|-----------------------|--------------------| -| validation.max_digits | field, digitsCount | - -| Param name | Description | -|-------------|---------------------------------------| -| field | The field under validation. | -| digitsCount | The max number of digits set in rule. | - - -## Max Length: `maxLength:value` -This rule checks the field under validation has length less than given value. - -```go -rulesMap := validation.RulesMap{ - "title": {"maxLength:50"} -} -``` - -### Translation - -| Key | Params | -|-----------------------|--------------| -| validation.max_length | field, value | - -| Param name | Description | -|------------|---------------------------------| -| field | The field under validation. | -| value | The maximum length set in rule. | - - -## Min: `min:value` -This rule checks the field under validation value be greater than given value. - -```go -rulesMap := validation.RulesMap{ - "age": {"min:30"} -} -``` - -### Translation - -| Key | Params | -|----------------|--------------| -| validation.min | field, value | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | -| value | The min value set in rule. | - - -## Min Digits: `minDigits:value` -This rule checks the field under validation has length greater than given min digits. - -```go -rulesMap := validation.RulesMap{ - "code": {"minDigits:5"} -} -``` - -### Translation - -| Key | Params | -|-----------------------|--------------------| -| validation.min_digits | field, digitsCount | - -| Param name | Description | -|-------------|---------------------------------------| -| field | The field under validation. | -| digitsCount | The min number of digits set in rule. | - - -## Min Length: `minLength:value` -This rule checks the field under validation has length greater than given value. - -```go -rulesMap := validation.RulesMap{ - "title": {"minLength:10"} -} -``` - -### Translation - -| Key | Params | -|-----------------------|--------------| -| validation.min_length | field, value | - -| Param name | Description | -|------------|---------------------------------| -| field | The field under validation. | -| value | The minimum length set in rule. | - - -## Not Equal: `neq:value` -This rule checks the field under validation is not equal to given value. - -```go -rulesMap := validation.RulesMap{ - "username": {"neq:admin"} -} -``` - -### Translation - -| Key | Params | -|----------------|--------------| -| validation.neq | field, value | - -| Param name | Description | -|------------|---------------------------------| -| field | The field under validation. | -| value | The given value in the rule. | - - -## Not Empty: `notEmpty` - -This rule checks the field under validation presents and have a non-empty or non-zero value. - -```go -rulesMap := validation.RulesMap{ - "username": {"notEmpty"} -} -``` - -### Translation - -| Key | Params | -|----------------------|--------| -| validation.not_empty | field | - -| Param name | Description | -|------------|---------------------------------| -| field | The field under validation. | - - -## Not In: `notIn:value1,value2[,value3,...]` - -This rule check the field under validation not exists in one of the given options. - -```go -rulesMap := validation.RulesMap{ - "username": {"notIn:admin,god,superuser"}, -} -``` - -### Translation - -| Key | Params | -|-------------------|--------| -| validation.not_in | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Not Regex: `notRegex:pattern` -This rule checks the field under validation does not match the given regex pattern. - -```go -rulesMap := validation.RulesMap{ - "username": {"notRegex:[\\s]+"}, -} -``` - -### Translation - -| Key | Params | -|----------------------|----------------| -| validation.not_regex | field, pattern | - -| Param name | Description | -|------------|--------------------------------| -| field | The field under validation. | -| pattern | The given pattern in the rule. | - - -## Numeric: `numeric` -This rule checks the field under validation has a numeric value. - - -```go -rulesMap := validation.RulesMap{ - "amount": {"numeric"}, -} -``` - -### Translation - -| Key | Params | -|--------------------|--------| -| validation.numeric | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Regex: `regex:pattern` -This rule checks the field under validation matches the given regex pattern. - -```go -rulesMap := validation.RulesMap{ - "username": {"regex:[a-zA-z0-9\\._]+"}, -} -``` - -### Translation - -| Key | Params | -|----------------------|----------------| -| validation.regex | field, pattern | - -| Param name | Description | -|------------|--------------------------------| -| field | The field under validation. | -| pattern | The given pattern in the rule. | - - -## Required: `required` -This rule checks the field under validation exists. - -```go -rulesMap := validation.RulesMap{ - "username": {"required"}, -} -``` - -### Translation - -| Key | Params | -|---------------------|--------| -| validation.required | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Required If: `requiredIf:otherField,value` -This rule check the field under validation exists if the given condition is true. -The condition is consists of another field name and a value. If the value of the other field is equal to -the given value, the field under validation is required. - -> Note that the only supported type for value parameter is string. - -```go -rulesMap := validation.RulesMap{ - "username": {"requiredIf:type,user"}, -} -``` - -### Translation - -| Key | Params | -|------------------------|--------------------------| -| validation.required_if | field, otherField, value | - -| Param name | Description | -|------------|--------------------------------------| -| field | The field under validation. | -| otherField | The other field to check its value. | -| value | The given value for the other value. | - - -## Required Unless: `requiredUnless:otherField,value` -This rule check the field under validation exists unless the given condition is true. -The condition is consists of another field name and a value. Unless the value of the other field is equal to -the given value, the field under validation is required. - -> Note that the only supported type for value parameter is string. - -```go -rulesMap := validation.RulesMap{ - "username": {"requiredUnless:type,admin"}, -} -``` - -### Translation - -| Key | Params | -|----------------------------|--------------------------| -| validation.required_unless | field, otherField, value | - -| Param name | Description | -|------------|--------------------------------------| -| field | The field under validation. | -| otherField | The other field to check its value. | -| value | The given value for the other value. | - - -## Required With: `requiredWith:otherField[,anotherField,...]` -This rule check the field under validation exists if any of given fields exist. - -```go -rulesMap := validation.RulesMap{ - "username": {"requiredWith:phone,email"}, -} -``` - -### Translation - -| Key | Params | -|--------------------------|-------------------| -| validation.required_with | field, otherField | - -| Param name | Description | -|------------|--------------------------------------| -| field | The field under validation. | -| otherField | The other field to check its value. | - - -## Required With All: `requiredWithAll:otherField,anotherField[,...]` -This rule check the field under validation exists if all given fields exist. - -```go -rulesMap := validation.RulesMap{ - "username": {"requiredWithAll:phone,email"}, -} -``` - -### Translation - -| Key | Params | -|------------------------------|--------------------| -| validation.required_with_all | field, otherFields | - -| Param name | Description | -|-------------|--------------------------------------| -| field | The field under validation. | -| otherFields | The other field to check its value. | - - -## Required Without: `requiredWithout:otherField[,anotherField,...]` -This rule check the field under validation exists if any of given fields doesn't exist. - -```go -rulesMap := validation.RulesMap{ - "username": {"requiredWithout:phone,email"}, -} -``` - -### Translation - -| Key | Params | -|-----------------------------|-------------------| -| validation.required_without | field, otherField | - -| Param name | Description | -|------------|--------------------------------------| -| field | The field under validation. | -| otherField | The other field to check its value. | - - -## Required Without All: `requiredWithoutAll:otherField,anotherField[,...]` -This rule check the field under validation exists if all given fields not exist. - -```go -rulesMap := validation.RulesMap{ - "username": {"requiredWithoutAll:phone,email"}, -} -``` - -### Translation - -| Key | Params | -|---------------------------------|--------------------| -| validation.required_without_all | field, otherFields | - -| Param name | Description | -|-------------|--------------------------------------| -| field | The field under validation. | -| otherFields | The other field to check its value. | - - -## Same As: `sameAs:otherField` -This rule check the field under validation has the value same as the other given field. - -```go -rulesMap := validation.RulesMap{ - "passwordConfirmation": {"sameAs:password"}, -} -``` - -### Translation - -| Key | Params | -|---------------------|-------------------| -| validation.same_as | field, otherField | - -| Param name | Description | -|------------|--------------------------------------| -| field | The field under validation. | -| otherField | The other field to check its value. | - - -## Starts With: `startsWith:prefix` -This rule check the field under validation starts with given sub string. - -```go -rulesMap := validation.RulesMap{ - "functionName": {"startsWith:Set"} -} -``` - -> The `prefix` parameter is case-sensitive. - -### Translation - -| Key | Params | -|-------------------------|--------------| -| validation.starts_with | field, value | - -| Param name | Description | -|------------|-------------------------------| -| field | The field under validation. | -| value | The prefix value set in rule. | - -## Timezone: `timezone` -This rule checks the field under validation is a valid IANA time zone name. - -```go -rulesMap := validation.RulesMap{ - "tz": {"timezone"}, -} -``` - -### Translation - -| Key | Params | -|---------------------|--------| -| validation.timezone | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## String: `string` -This rule checks the field under validation is a string. - -```go -rulesMap := validation.RulesMap{ - "title": {"string"}, -} -``` - -### Translation - -| Key | Params | -|-------------------|--------| -| validation.string | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## Uppercase: `uppercase` -This rule checks the field under validation is all uppercase. - -```go -rulesMap := validation.RulesMap{ - "code": {"uppercase"}, -} -``` - -### Translation - -| Key | Params | -|----------------------|--------| -| validation.uppercase | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## URL: `url[:scheme]` -This rule checks the field under validation is a valid URL. By default, the scheme is optional; pass `scheme` to require it. - -```go -rulesMap := validation.RulesMap{ - "site": {"url"}, // scheme optional - "strict": {"url:scheme"}, // scheme required -} -``` - -### Translation - -| Key | Params | -|----------------|--------| -| validation.url | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | - - -## UUID: `uuid` -This rule checks field under validation is a valid uuid. - -```go -rulesMap := validation.RulesMap{ - "id": {"uuid"}, -} -``` - -### Translation - -| Key | Params | -|-----------------|--------| -| validation.uuid | field | - -| Param name | Description | -|------------|-----------------------------| -| field | The field under validation. | diff --git a/rules/after.go b/rules/after.go deleted file mode 100644 index 0f3d955..0000000 --- a/rules/after.go +++ /dev/null @@ -1,81 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// After checks whether the field under validation has a value that is after the value of the given field. It will -// return a validation error if one or both of the fields are not valid datetime strings. It will also return a -// validation error if the other field cannot be found in the input bag. -// -// Usage: "after:otherField[,timeZoneString]". -// Example: "after:start". -// Example: "after:start,America/New_York". -type After struct { - translation.BaseTranslatableRule - otherField string - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that occurs after the datetime value -// of another specified field. It returns a ValidationResult that indicates success if valid, or the appropriate error -// message if the check fails, the datetime formats are invalid, or the other field is missing. -func (r *After) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - otherValue, ok := inputBag.Get(r.otherField) - if !ok { - return NewFailedResult(r.Translate(r.Locale, "Validation.required", map[string]string{ - "field": r.otherField, - })) - } - - otherTimeValue, err := cast.ToTimeInDefaultLocationE(otherValue, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": r.otherField, - })) - } - - result := timeValue.After(otherTimeValue) - - if !result { - return NewFailedResult(r.Translate(r.Locale, "validation.after", map[string]string{ - "field": selector, - "otherField": r.otherField, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the After rule instance. -// The first parameter specifies the `otherField` to compare against (required), -// and the second parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *After) AddParams(params []string) { - r.otherField = params[0] - r.timeZone = time.UTC - - if len(params) > 1 { - if tz, err := time.LoadLocation(params[1]); err == nil { - r.timeZone = tz - } - } -} - -// MinRequiredParams returns the minimum number of required parameters for the After rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory, while the `timeZoneString` parameter is optional. -func (*After) MinRequiredParams() int { - return 1 -} diff --git a/rules/after_or_equal.go b/rules/after_or_equal.go deleted file mode 100644 index 866fb68..0000000 --- a/rules/after_or_equal.go +++ /dev/null @@ -1,81 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// AfterOrEqual checks whether the field under validation has a value that is after or equal to the value of the given -// field. It will return a validation error if one or both of the fields are not valid datetime strings. It will also -// return a validation error if the other field cannot be found in the input bag. -// -// Usage: "afterOrEqual:otherField[,timeZoneString]". -// Example: "afterOrEqual:start". -// Example: "afterOrEqual:start,America/New_York". -type AfterOrEqual struct { - translation.BaseTranslatableRule - otherField string - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that is after or equal to the -// datetime of another specified field. Returns a ValidationResult indicating success if valid, or the appropriate -// error message if the check fails, the datetime formats are invalid, or the other field is missing. -func (r *AfterOrEqual) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - otherValue, ok := inputBag.Get(r.otherField) - if !ok { - return NewFailedResult(r.Translate(r.Locale, "Validation.required", map[string]string{ - "otherField": r.otherField, - })) - } - - otherTimeValue, err := cast.ToTimeInDefaultLocationE(otherValue, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": r.otherField, - })) - } - - result := timeValue.After(otherTimeValue) || timeValue.Equal(otherTimeValue) - - if !result { - return NewFailedResult(r.Translate(r.Locale, "validation.after_or_equal", map[string]string{ - "field": selector, - "otherField": r.otherField, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the AfterOrEqual rule instance. -// The first parameter specifies the `otherField` to compare against (required), -// and the second parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *AfterOrEqual) AddParams(params []string) { - r.otherField = params[0] - r.timeZone = time.UTC - - if len(params) > 1 { - if tz, err := time.LoadLocation(params[1]); err == nil { - r.timeZone = tz - } - } -} - -// MinRequiredParams returns the minimum number of required parameters for the AfterOrEqual rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory, while the `timeZoneString` parameter is optional. -func (*AfterOrEqual) MinRequiredParams() int { - return 1 -} diff --git a/rules/after_or_equal_test.go b/rules/after_or_equal_test.go deleted file mode 100644 index b3df239..0000000 --- a/rules/after_or_equal_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var afterOrEqualRuleTestData = map[string]any{ - "successfulAfter": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulEqual": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "2022-01-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notAfterOrEqual": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2023-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be after or equal to field start.", - }, - }, - "invalidFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "invalid", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be a valid date time string.", - }, - }, - "OtherFieldNotProvided": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "end": "2022-05-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start is required.", - }, - }, - "invalidOtherFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "invalid", - "end": "2022-01-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be a valid date time string.", - }, - }, -} - -func TestAfterOrEqualRule(t *testing.T) { - rule := initAfterOrEqualRule() - - for name, d := range afterOrEqualRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initAfterOrEqualRule() *AfterOrEqual { - afterOrEqualRule := &AfterOrEqual{} - afterOrEqualRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.after_or_equal": - tr := "The field :field: must be after or equal to field :otherField:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "Validation.required": - tr := "The field :otherField: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - - return afterOrEqualRule -} - -func TestAfterOrEqual_MinRequiredParams(t *testing.T) { - rule := initAfterOrEqualRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/after_test.go b/rules/after_test.go deleted file mode 100644 index 31231d6..0000000 --- a/rules/after_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var afterRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notAfter": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2023-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be after field start.", - }, - }, - "invalidFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "invalid", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be a valid date time string.", - }, - }, - "OtherFieldNotProvided": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "end": "2022-05-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start is required.", - }, - }, - "invalidOtherFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{ - "start": "invalid", - "end": "2022-01-01", - }, - "params": []string{ - "start", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be a valid date time string.", - }, - }, -} - -func TestAfterRule(t *testing.T) { - rule := initAfterRule() - - for name, d := range afterRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initAfterRule() *After { - afterRule := &After{} - afterRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.after": - tr := "The field :field: must be after field :otherField:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "Validation.required": - tr := "The field :field: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return afterRule -} - -func TestAfter_MinRequiredParams(t *testing.T) { - rule := initAfterRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/alpha.go b/rules/alpha.go deleted file mode 100644 index 3d1aabd..0000000 --- a/rules/alpha.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Alpha checks whether the field under validation contains only alphabetic characters. -// This rule accepts no parameters. -// -// Usage: "alpha". -type Alpha struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation contains only alphabetic characters. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Alpha) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(`^[\pL\pM]+$`, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.alpha", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/alpha_dash.go b/rules/alpha_dash.go deleted file mode 100644 index 1a5d90f..0000000 --- a/rules/alpha_dash.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// AlphaDash checks whether the field under validation contains only alphanumeric characters, dashes, and underscores. -// This rule accepts no parameters. -// -// Usage: "alphaDash". -type AlphaDash struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation contains only alphanumeric characters, dashes, and underscores. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *AlphaDash) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(`^[\pL\pM\pN_-]+$`, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.alpha_dash", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/alpha_dash_test.go b/rules/alpha_dash_test.go deleted file mode 100644 index 80d0904..0000000 --- a/rules/alpha_dash_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var alphaDashRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "title", - "inputBag": bag.InputBag{ - "title": "user-content_1", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notAlphaDash": map[string]any{ - "input": map[string]any{ - "selector": "title", - "inputBag": bag.InputBag{ - "title": "user-content 1", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field title must only contain letters, numbers, dashes and underscores.", - }, - }, -} - -func TestAlphaDashRule(t *testing.T) { - rule := initAlphaDashRule() - - for name, d := range alphaDashRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initAlphaDashRule() *AlphaDash { - alphaRule := &AlphaDash{} - alphaRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.alpha_dash": - tr := "The field :field: must only contain letters, numbers, dashes and underscores." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return alphaRule -} diff --git a/rules/alpha_num.go b/rules/alpha_num.go deleted file mode 100644 index 2629fda..0000000 --- a/rules/alpha_num.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// AlphaNum checks whether the field under validation contains only alphanumeric characters. -// This rule accepts no parameters. -// -// Usage: "alphaNum". -type AlphaNum struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation contains only alphanumeric characters. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *AlphaNum) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(`^[\pL\pM\pN]+$`, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.alpha_num", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/alpha_num_test.go b/rules/alpha_num_test.go deleted file mode 100644 index 70b15b2..0000000 --- a/rules/alpha_num_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var alphaNumRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "title", - "inputBag": bag.InputBag{ - "title": "user1", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notAlphaNum": map[string]any{ - "input": map[string]any{ - "selector": "title", - "inputBag": bag.InputBag{ - "title": "user-content 1", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field title must only contain letters and numbers.", - }, - }, -} - -func TestAlphaNumRule(t *testing.T) { - rule := initAlphaNumRule() - - for name, d := range alphaNumRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initAlphaNumRule() *AlphaNum { - alphaRule := &AlphaNum{} - alphaRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.alpha_num": - tr := "The field :field: must only contain letters and numbers." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return alphaRule -} diff --git a/rules/alpha_space.go b/rules/alpha_space.go deleted file mode 100644 index 8b527ef..0000000 --- a/rules/alpha_space.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// AlphaSpace checks whether the field under validation contains only alphabetic characters and spaces. -// This rule accepts no parameters. -// -// Usage: "alphaSpace". -type AlphaSpace struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation contains only alphabetic characters and spaces. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *AlphaSpace) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(`^[\pL\pM\s]+$`, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.alpha_space", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/alpha_space_test.go b/rules/alpha_space_test.go deleted file mode 100644 index 904a764..0000000 --- a/rules/alpha_space_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var alphaSpaceRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "fullName", - "inputBag": bag.InputBag{ - "fullName": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notAlphaSpace": map[string]any{ - "input": map[string]any{ - "selector": "fullName", - "inputBag": bag.InputBag{ - "fullName": "John Doe 3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field fullName must only contain letters and spaces.", - }, - }, -} - -func TestAlphaSpaceRule(t *testing.T) { - rule := initAlphaSpaceRule() - - for name, d := range alphaSpaceRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initAlphaSpaceRule() *AlphaSpace { - alphaRule := &AlphaSpace{} - alphaRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.alpha_space": - tr := "The field :field: must only contain letters and spaces." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return alphaRule -} diff --git a/rules/alpha_test.go b/rules/alpha_test.go deleted file mode 100644 index ba73955..0000000 --- a/rules/alpha_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var alphaRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notAlpha": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must only contain letters.", - }, - }, -} - -func TestAlphaRule(t *testing.T) { - rule := initAlphaRule() - - for name, d := range alphaRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initAlphaRule() *Alpha { - alphaRule := &Alpha{} - alphaRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.alpha": - tr := "The field :field: must only contain letters." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return alphaRule -} diff --git a/rules/array.go b/rules/array.go deleted file mode 100644 index 1f0e6eb..0000000 --- a/rules/array.go +++ /dev/null @@ -1,28 +0,0 @@ -package rules - -import ( - "github.com/thoas/go-funk" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Array checks whether the field under validation is an array or slice. -// This rule accepts no parameters. -// -// Usage: "array". -type Array struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is an array or slice. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Array) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if !funk.IsCollection(value) { - return NewFailedResult(r.Translate(r.Locale, "validation.array", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/array_test.go b/rules/array_test.go deleted file mode 100644 index dcd22a3..0000000 --- a/rules/array_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var arrayRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"go", "mongodb"}, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notArray": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": map[string]any{ - "backend": "go", - "database": "mongo", - }, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must be an array or slice.", - }, - }, -} - -func TestArrayRule(t *testing.T) { - rule := initArrayRule() - - for name, d := range arrayRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initArrayRule() *Array { - alphaRule := &Array{} - alphaRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.array": - tr := "The field :field: must be an array or slice." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return alphaRule -} diff --git a/rules/before.go b/rules/before.go deleted file mode 100644 index 801d02a..0000000 --- a/rules/before.go +++ /dev/null @@ -1,81 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Before checks whether the field under validation has a value that is before the value of the given field. It will -// return a validation error if one or both of the fields are not valid datetime strings. It will also return a -// validation error if the other field cannot be found in the input bag. -// -// Usage: "before:otherField[,timeZoneString]. -// Example: "before:end". -// Example: "before:end,America/New_York". -type Before struct { - translation.BaseTranslatableRule - otherField string - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that occurs before the datetime -// value of another specified field. It returns a ValidationResult that indicates success if valid, or the appropriate -// error message if the check fails, the datetime formats are invalid, or the other field is missing. -func (r *Before) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - otherValue, ok := inputBag.Get(r.otherField) - if !ok { - return NewFailedResult(r.Translate(r.Locale, "Validation.required", map[string]string{ - "otherField": r.otherField, - })) - } - - otherTimeValue, err := cast.ToTimeInDefaultLocationE(otherValue, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": r.otherField, - })) - } - - result := timeValue.Before(otherTimeValue) - - if !result { - return NewFailedResult(r.Translate(r.Locale, "validation.before", map[string]string{ - "field": selector, - "otherField": r.otherField, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Before rule instance. -// The first parameter specifies the `otherField` to compare against (required), -// and the second parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *Before) AddParams(params []string) { - r.otherField = params[0] - r.timeZone = time.UTC - - if len(params) > 1 { - if tz, err := time.LoadLocation(params[1]); err == nil { - r.timeZone = tz - } - } -} - -// MinRequiredParams returns the minimum number of required parameters for the Before rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory, while the `timeZoneString` parameter is optional. -func (*Before) MinRequiredParams() int { - return 1 -} diff --git a/rules/before_or_equal.go b/rules/before_or_equal.go deleted file mode 100644 index c796313..0000000 --- a/rules/before_or_equal.go +++ /dev/null @@ -1,81 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// BeforeOrEqual checks whether the field under validation has a value that is before or equal to the value of the given -// field. It will return a validation error if one or both of the fields are not valid datetime strings. It will also -// return a validation error if the other field cannot be found in the input bag. -// -// Usage: "beforeOrEqual:otherField[,timeZoneString]. -// Example: "beforeOrEqual:end". -// Example: "beforeOrEqual:end,America/New_York". -type BeforeOrEqual struct { - translation.BaseTranslatableRule - otherField string - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that is before or equal to the datetime -// value of another specified field. It returns a ValidationResult that indicates success if valid, or the appropriate -// error message if the check fails, the datetime formats are invalid, or the other field is missing. -func (r *BeforeOrEqual) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - otherValue, ok := inputBag.Get(r.otherField) - if !ok { - return NewFailedResult(r.Translate(r.Locale, "Validation.required", map[string]string{ - "otherField": r.otherField, - })) - } - - otherTimeValue, err := cast.ToTimeInDefaultLocationE(otherValue, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": r.otherField, - })) - } - - result := timeValue.Before(otherTimeValue) || timeValue.Equal(otherTimeValue) - - if !result { - return NewFailedResult(r.Translate(r.Locale, "validation.before_or_equal", map[string]string{ - "field": selector, - "otherField": r.otherField, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the BeforeOrEqual rule instance. -// The first parameter specifies the `otherField` to compare against (required), -// and the second parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *BeforeOrEqual) AddParams(params []string) { - r.otherField = params[0] - r.timeZone = time.UTC - - if len(params) > 1 { - if tz, err := time.LoadLocation(params[1]); err == nil { - r.timeZone = tz - } - } -} - -// MinRequiredParams returns the minimum number of required parameters for the BeforeOrEqual rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory, while the `timeZoneString` parameter is optional. -func (*BeforeOrEqual) MinRequiredParams() int { - return 1 -} diff --git a/rules/before_or_equal_test.go b/rules/before_or_equal_test.go deleted file mode 100644 index 5a4c3c0..0000000 --- a/rules/before_or_equal_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var beforeOrEqualRuleTestData = map[string]any{ - "successfulBefore": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulEqual": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "2022-01-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notBeforeOrEqual": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2023-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be before or equal to field end.", - }, - }, - "invalidFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "invalid", - "end": "2022-01-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be a valid date time string.", - }, - }, - "OtherFieldNotProvided": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end is required.", - }, - }, - "invalidOtherFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "invalid", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be a valid date time string.", - }, - }, -} - -func TestBeforeOrEqualRule(t *testing.T) { - rule := initBeforeOrEqualRule() - - for name, d := range beforeOrEqualRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initBeforeOrEqualRule() *BeforeOrEqual { - beforeOrEqualRule := &BeforeOrEqual{} - beforeOrEqualRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.before_or_equal": - tr := "The field :field: must be before or equal to field :otherField:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "Validation.required": - tr := "The field :otherField: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - - return beforeOrEqualRule -} - -func TestBeforeOrEqual_MinRequiredParams(t *testing.T) { - rule := initBeforeOrEqualRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/before_test.go b/rules/before_test.go deleted file mode 100644 index 5bd0888..0000000 --- a/rules/before_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var beforeRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notBefore": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2023-01-01", - "end": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be before field end.", - }, - }, - "invalidFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "invalid", - "end": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be a valid date time string.", - }, - }, - "OtherFieldNotProvided": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-05-01", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end is required.", - }, - }, - "invalidOtherFieldValue": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{ - "start": "2022-01-01", - "end": "invalid", - }, - "params": []string{ - "end", "America/New_York", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be a valid date time string.", - }, - }, -} - -func TestBeforeRule(t *testing.T) { - rule := initBeforeRule() - - for name, d := range beforeRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initBeforeRule() *Before { - beforeRule := &Before{} - beforeRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.before": - tr := "The field :field: must be before field :otherField:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "Validation.required": - tr := "The field :otherField: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return beforeRule -} - -func TestBefore_MinRequiredParams(t *testing.T) { - rule := initBeforeRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/between.go b/rules/between.go deleted file mode 100644 index d68f014..0000000 --- a/rules/between.go +++ /dev/null @@ -1,69 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Between checks whether the field under validation has a value or a length between the given min and max parameters. -// It will return a validation error if the field under validation has a numeric value, the value must be between the -// given min and max. If the field under validation is string, slice or map, the length of it will be evaluated. -// -// Usage: "between:min,max". -// Example: "between:2,5". -type Between struct { - translation.BaseTranslatableRule - min, max float64 -} - -// Validate checks if the value of the field under validation is between the given min and max parameters. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Between) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var floatValue float64 - switch v.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - floatValue = cast.ToFloat64(value) - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - floatValue = float64(v.Len()) - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if floatValue < r.min || floatValue > r.max { - return NewFailedResult(r.Translate(r.Locale, "validation.between", map[string]string{ - "field": selector, - "min": cast.ToString(r.min), - "max": cast.ToString(r.max), - })) - } - - return NewSuccessResult() -} - -// AddParams sets the minimum and maximum values for the Between rule based on the provided parameters. -// It expects exactly two parameters: the first for the minimum boundary (min), -// and the second for the maximum boundary (max). The extracted values are converted to float64 and stored. -// Returns nothing. -func (r *Between) AddParams(params []string) { - r.min = cast.ToFloat64(params[0]) - r.max = cast.ToFloat64(params[1]) -} - -// MinRequiredParams returns the minimum number of required parameters for the Between rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `min` and `max` parameters are mandatory. -func (*Between) MinRequiredParams() int { - return 2 -} diff --git a/rules/between_test.go b/rules/between_test.go deleted file mode 100644 index 1c6b803..0000000 --- a/rules/between_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var betweenRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "18", "30", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "80", "90", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomethingUseful", - }, - "params": []string{ - "3", "30", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "2", "5", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "2", "5", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExist": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{}, - "params": []string{ - "18", "30", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherType": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", "30", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "8", "20", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have a value or length between 8 and 20.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "90", "99.99", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must have a value or length between 90 and 99.99.", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "do", - }, - "params": []string{ - "3", "30", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must have a value or length between 3 and 30.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "2", "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must have a value or length between 2 and 3.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - "score": 86.59, - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - "active": true, - }, - }, - "params": []string{ - "2", "5", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must have a value or length between 2 and 5.", - }, - }, -} - -func TestBetweenRule(t *testing.T) { - rule := initBetweenRule() - - for name, d := range betweenRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initBetweenRule() *Between { - betweenRule := &Between{} - betweenRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.between": - tr := "The field :field: must have a value or length between :min: and :max:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return betweenRule -} - -func TestBetween_MinRequiredParams(t *testing.T) { - rule := initBetweenRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} diff --git a/rules/boolean.go b/rules/boolean.go deleted file mode 100644 index 362bd3e..0000000 --- a/rules/boolean.go +++ /dev/null @@ -1,29 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Boolean checks whether the field under validation is boolean or can be cast as a boolean value. -// This rule accepts no parameters. -// -// Usage: "boolean". -type Boolean struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a boolean or can be cast as a boolean value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Boolean) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - _, err := cast.ToBoolE(value) - if err != nil || value == nil { - return NewFailedResult(r.Translate(r.Locale, "validation.boolean", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/boolean_test.go b/rules/boolean_test.go deleted file mode 100644 index 91d3eb1..0000000 --- a/rules/boolean_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var booleanRuleTestData = map[string]any{ - "successfulBoolean": map[string]any{ - "input": map[string]any{ - "selector": "agree", - "inputBag": bag.InputBag{ - "agree": true, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulInt": map[string]any{ - "input": map[string]any{ - "selector": "agree", - "inputBag": bag.InputBag{ - "agree": 1, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "agree", - "inputBag": bag.InputBag{ - "agree": "true", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInvalidString": map[string]any{ - "input": map[string]any{ - "selector": "agree", - "inputBag": bag.InputBag{ - "agree": "invalidString", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field agree must be boolean.", - }, - }, - "failedNil": map[string]any{ - "input": map[string]any{ - "selector": "agree", - "inputBag": bag.InputBag{ - "agree": nil, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field agree must be boolean.", - }, - }, - "failedNotExists": map[string]any{ - "input": map[string]any{ - "selector": "agree", - "inputBag": bag.InputBag{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field agree must be boolean.", - }, - }, -} - -func TestBooleanRule(t *testing.T) { - rule := initBooleanRule() - - for name, d := range booleanRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initBooleanRule() *Boolean { - booleanRule := &Boolean{} - booleanRule.AddTranslationFunction(func(local, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.boolean": - tr := "The field :field: must be boolean." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - - return booleanRule -} diff --git a/rules/contracts.go b/rules/contracts.go deleted file mode 100644 index f53b8af..0000000 --- a/rules/contracts.go +++ /dev/null @@ -1,56 +0,0 @@ -package rules - -import ( - "github.com/behzadsh/go.validator/bag" -) - -// ValidationResult is a struct that represents a single validation result. -type ValidationResult struct { - valid bool - message string -} - -// NewResult generates a ValidationResult instance with the given values. -func NewResult(valid bool, message string) ValidationResult { - return ValidationResult{valid: valid, message: message} -} - -// NewFailedResult generates a ValidationResult instance as failed validation with the given error message. -func NewFailedResult(message string) ValidationResult { - return ValidationResult{valid: false, message: message} -} - -// NewSuccessResult generates a ValidationResult instance as a success result with an empty message. -func NewSuccessResult() ValidationResult { - return ValidationResult{valid: true, message: ""} -} - -// Message returns the message stored in the ValidationResult instance. -// It returns the message associated with the validation result. -func (r ValidationResult) Message() string { - return r.message -} - -// Failed returns true if the validation has failed. -func (r ValidationResult) Failed() bool { - return !r.valid -} - -// Rule is an interface for rules. -type Rule interface { - Validate(selector string, value any, inputBag bag.InputBag) ValidationResult -} - -// FieldRequiredRule is an interface for rules that require the field to exist in order to run validation. -// Rules not implementing this interface are skipped when the field is absent. -type FieldRequiredRule interface { - Rule - RequiresField() bool -} - -// RuleWithParams is an interface for rules that have parameters. -type RuleWithParams interface { - Rule - AddParams(params []string) - MinRequiredParams() int -} diff --git a/rules/datetime.go b/rules/datetime.go deleted file mode 100644 index a50da23..0000000 --- a/rules/datetime.go +++ /dev/null @@ -1,29 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// DateTime checks whether the field under validation is a valid datetime string and can be cast to time.Time. -// This rule accepts no parameters. -// -// Usage: "datetime". -type DateTime struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid datetime string and can be cast to time.Time. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *DateTime) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - _, err := cast.StringToDate(cast.ToString(value)) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/datetime_after.go b/rules/datetime_after.go deleted file mode 100644 index 9b7c3c9..0000000 --- a/rules/datetime_after.go +++ /dev/null @@ -1,64 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// DateTimeAfter checks whether the field under validation is a datetime string that occurs after the given datetime value. -// -// Usage: "dateTimeAfter:value[,timeZone]". -// Example: "dateTimeAfter:2021-01-01". -// Example: "dateTimeAfter:2021-01-01,America/New_York". -type DateTimeAfter struct { - translation.BaseTranslatableRule - threshold time.Time - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that occurs after the given datetime value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *DateTimeAfter) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - if !timeValue.After(r.threshold) { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime_after", map[string]string{ - "field": selector, - "value": r.threshold.Format(time.RFC3339), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the DateTimeAfter rule instance. -// The first parameter specifies the `value` to compare against (required), -// and the second parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *DateTimeAfter) AddParams(params []string) { - r.timeZone = time.UTC - if len(params) > 1 { - if tz, err := time.LoadLocation(params[1]); err == nil { - r.timeZone = tz - } - } - - // parse threshold in given timezone - t, err := cast.ToTimeInDefaultLocationE(params[0], r.timeZone) - if err == nil { - r.threshold = t - } -} - -// MinRequiredParams returns the minimum number of required parameters for the DateTimeAfter rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory, while the `timeZone` parameter is optional. -func (*DateTimeAfter) MinRequiredParams() int { return 1 } diff --git a/rules/datetime_after_test.go b/rules/datetime_after_test.go deleted file mode 100644 index 9dd6a9e..0000000 --- a/rules/datetime_after_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestDateTimeAfterRule(t *testing.T) { - rule := initDateTimeAfterRule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{"start": "2022-02-01"}, - "params": []string{"2022-01-01", "America/New_York"}, - }, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalidField": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{"start": "invalid"}, - "params": []string{"2022-01-01"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be a valid date time string.", - }, - }, - "notAfter": map[string]any{ - "input": map[string]any{ - "selector": "start", - "inputBag": bag.InputBag{"start": "2021-12-31"}, - "params": []string{"2022-01-01"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field start must be after 2022-01-01T00:00:00Z.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDateTimeAfterRule() *DateTimeAfter { - r := &DateTimeAfter{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - case "validation.datetime_after": - tr := "The field :field: must be after :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - case "validation.after": - tr := "The field :field: must be after field :otherField:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/datetime_before.go b/rules/datetime_before.go deleted file mode 100644 index 00d2cb3..0000000 --- a/rules/datetime_before.go +++ /dev/null @@ -1,63 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// DateTimeBefore checks whether the field under validation is a datetime string that occurs before the given datetime value. -// -// Usage: "dateTimeBefore:value[,timeZone]". -// Example: "dateTimeBefore:2021-01-01". -// Example: "dateTimeBefore:2021-01-01,America/New_York". -type DateTimeBefore struct { - translation.BaseTranslatableRule - threshold time.Time - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that occurs before the given datetime value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *DateTimeBefore) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - if !timeValue.Before(r.threshold) { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime_before", map[string]string{ - "field": selector, - "value": r.threshold.Format(time.RFC3339), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the DateTimeBefore rule instance. -// The first parameter specifies the `value` to compare against (required), -// and the second parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *DateTimeBefore) AddParams(params []string) { - r.timeZone = time.UTC - if len(params) > 1 { - if tz, err := time.LoadLocation(params[1]); err == nil { - r.timeZone = tz - } - } - - t, err := cast.ToTimeInDefaultLocationE(params[0], r.timeZone) - if err == nil { - r.threshold = t - } -} - -// MinRequiredParams returns the minimum number of required parameters for the DateTimeBefore rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory, while the `timeZone` parameter is optional. -func (*DateTimeBefore) MinRequiredParams() int { return 1 } diff --git a/rules/datetime_before_test.go b/rules/datetime_before_test.go deleted file mode 100644 index 522fffe..0000000 --- a/rules/datetime_before_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestDateTimeBeforeRule(t *testing.T) { - rule := initDateTimeBeforeRule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{"end": "2021-12-31"}, - "params": []string{"2022-01-01", "America/New_York"}, - }, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalidField": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{"end": "invalid"}, - "params": []string{"2022-01-01"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be a valid date time string.", - }, - }, - "notBefore": map[string]any{ - "input": map[string]any{ - "selector": "end", - "inputBag": bag.InputBag{"end": "2022-02-01"}, - "params": []string{"2022-01-01"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field end must be before 2022-01-01T00:00:00Z.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDateTimeBeforeRule() *DateTimeBefore { - r := &DateTimeBefore{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - case "validation.datetime_before": - tr := "The field :field: must be before :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/datetime_between.go b/rules/datetime_between.go deleted file mode 100644 index 3c1f8f3..0000000 --- a/rules/datetime_between.go +++ /dev/null @@ -1,68 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// DateTimeBetween checks whether the field under validation is a datetime string that occurs between two given datetime values (inclusive). -// -// Usage: "dateTimeBetween:min,max[,timeZone]". -// Example: "dateTimeBetween:2021-01-01,2021-01-02". -// Example: "dateTimeBetween:2021-01-01,2021-01-02,America/New_York". -type DateTimeBetween struct { - translation.BaseTranslatableRule - min time.Time - max time.Time - timeZone *time.Location -} - -// Validate checks if the value of the field under validation is a datetime string that occurs between two given datetime values (inclusive). -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *DateTimeBetween) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - timeValue, err := cast.ToTimeInDefaultLocationE(value, r.timeZone) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime", map[string]string{ - "field": selector, - })) - } - - if timeValue.Before(r.min) || timeValue.After(r.max) { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime_between", map[string]string{ - "field": selector, - "min": r.min.Format(time.RFC3339), - "max": r.max.Format(time.RFC3339), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the DateTimeBetween rule instance. -// The first parameter specifies the `min` value to compare against (required), -// the second parameter specifies the `max` value to compare against (required), -// and the third parameter, if provided, sets the time zone for parsing date/time values (optional). -func (r *DateTimeBetween) AddParams(params []string) { - r.timeZone = time.UTC - if len(params) > 2 { - if tz, err := time.LoadLocation(params[2]); err == nil { - r.timeZone = tz - } - } - - if t, err := cast.ToTimeInDefaultLocationE(params[0], r.timeZone); err == nil { - r.min = t - } - if t, err := cast.ToTimeInDefaultLocationE(params[1], r.timeZone); err == nil { - r.max = t - } -} - -// MinRequiredParams returns the minimum number of required parameters for the DateTimeBetween rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `min` and `max` parameters are mandatory, while the `timeZone` parameter is optional. -func (*DateTimeBetween) MinRequiredParams() int { return 2 } diff --git a/rules/datetime_between_test.go b/rules/datetime_between_test.go deleted file mode 100644 index b536bc1..0000000 --- a/rules/datetime_between_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestDateTimeBetweenRule(t *testing.T) { - rule := initDateTimeBetweenRule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{ - "selector": "d", - "inputBag": bag.InputBag{"d": "2022-01-15"}, - "params": []string{"2022-01-01", "2022-02-01", "UTC"}, - }, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalidField": map[string]any{ - "input": map[string]any{ - "selector": "d", - "inputBag": bag.InputBag{"d": "invalid"}, - "params": []string{"2022-01-01", "2022-02-01"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field d must be a valid date time string.", - }, - }, - "outOfRange": map[string]any{ - "input": map[string]any{ - "selector": "d", - "inputBag": bag.InputBag{"d": "2021-12-31"}, - "params": []string{"2022-01-01", "2022-02-01"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field d must be between 2022-01-01T00:00:00Z and 2022-02-01T00:00:00Z.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDateTimeBetweenRule() *DateTimeBetween { - r := &DateTimeBetween{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - case "validation.datetime_between": - tr := "The field :field: must be between :min: and :max:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/datetime_format.go b/rules/datetime_format.go deleted file mode 100644 index fd14ca0..0000000 --- a/rules/datetime_format.go +++ /dev/null @@ -1,50 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// DateTimeFormat checks whether the field under validation is a valid datetime string that matches the given format. -// -// Usage: "datetimeFormat:format". -// Example: "datetimeFormat:2006-01-02T15:04:05Z07:00". -type DateTimeFormat struct { - translation.BaseTranslatableRule - layout string -} - -// Validate checks if the value of the field under validation is a valid datetime string that matches the given format. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *DateTimeFormat) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - if !inputBag.Has(selector) { - return NewSuccessResult() - } - - _, err := time.Parse(r.layout, cast.ToString(value)) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.datetime_format", map[string]string{ - "field": selector, - "format": r.layout, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the DateTimeFormat rule instance. -// The first parameter specifies the `format` to compare against (required). -func (r *DateTimeFormat) AddParams(params []string) { - r.layout = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the DateTimeFormat rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `format` parameter is mandatory. -func (*DateTimeFormat) MinRequiredParams() int { - return 1 -} diff --git a/rules/datetime_format_test.go b/rules/datetime_format_test.go deleted file mode 100644 index 4703371..0000000 --- a/rules/datetime_format_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package rules - -import ( - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var dateTimeFormatTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "loggedAt", - "inputBag": bag.InputBag{ - "loggedAt": "2022-12-28T23:54:34+03:30", - }, - "params": []string{ - time.RFC3339, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExist": map[string]any{ - "input": map[string]any{ - "selector": "loggedAt", - "inputBag": bag.InputBag{}, - "params": []string{ - time.RFC3339, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "incorrectFormat": map[string]any{ - "input": map[string]any{ - "selector": "loggedAt", - "inputBag": bag.InputBag{ - "loggedAt": "2022-12-28 23:54:34", - }, - "params": []string{ - time.RFC3339, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "the field loggedAt must be in 2006-01-02T15:04:05Z07:00 format.", - }, - }, -} - -func TestDateTimeFormatRule(t *testing.T) { - rule := initDateTimeFormatRule() - - for name, d := range dateTimeFormatTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDateTimeFormatRule() *DateTimeFormat { - dateTimeFormatRule := &DateTimeFormat{} - dateTimeFormatRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.datetime_format": - tr := "the field :field: must be in :format: format." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return dateTimeFormatRule -} - -func TestDateTimeFormat_MinRequiredParams(t *testing.T) { - rule := initDateTimeFormatRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/datetime_test.go b/rules/datetime_test.go deleted file mode 100644 index 9394b9e..0000000 --- a/rules/datetime_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var dateTimeRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "birthday", - "inputBag": bag.InputBag{ - "birthday": "1989-05-01", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "birthday", - "inputBag": bag.InputBag{ - "birthday": "invalid datetime string", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field birthday must be a valid date time string.", - }, - }, -} - -func TestDateTimeRule(t *testing.T) { - rule := initDateTimeRule() - - for name, d := range dateTimeRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDateTimeRule() *DateTime { - datetimeRule := &DateTime{} - datetimeRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.datetime": - tr := "The field :field: must be a valid date time string." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - - return datetimeRule -} diff --git a/rules/different.go b/rules/different.go deleted file mode 100644 index 6bf4e77..0000000 --- a/rules/different.go +++ /dev/null @@ -1,43 +0,0 @@ -package rules - -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Different checks whether the field under validation has a different value than the specified field. -// -// Usage: "different:otherField". -// Example" "different:oldPassword". -type Different struct { - translation.BaseTranslatableRule - otherField string -} - -// Validate checks if the value of the field under validation is different from the value of the specified field. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Different) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - otherValue, _ := inputBag.Get(r.otherField) - - if value == otherValue { - return NewFailedResult(r.Translate(r.Locale, "validation.different", map[string]string{ - "field": selector, - "otherField": r.otherField, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Different rule instance. -// The first parameter specifies the `otherField` to compare against (required). -func (r *Different) AddParams(params []string) { - r.otherField = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the Different rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory. -func (*Different) MinRequiredParams() int { - return 1 -} diff --git a/rules/different_test.go b/rules/different_test.go deleted file mode 100644 index 70c8e7e..0000000 --- a/rules/different_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var differentRuleTestData = map[string]any{ - "success": map[string]any{ - "input": map[string]any{ - "selector": "newPassword", - "inputBag": bag.InputBag{ - "oldPassword": "mySecurePassword", - "newPassword": "anotherSecurePassword", - }, - "params": []string{ - "oldPassword", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "newPassword", - "inputBag": bag.InputBag{ - "oldPassword": "mySecurePassword", - "newPassword": "mySecurePassword", - }, - "params": []string{ - "oldPassword", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field newPassword must be different from the field oldPassword.", - }, - }, -} - -func TestDifferentRule(t *testing.T) { - rule := initDifferentRule() - - for name, d := range differentRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDifferentRule() *Different { - differentRule := &Different{} - differentRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.different": - tr := "The field :field: must be different from the field :otherField:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return differentRule -} - -func TestDifferent_MinRequiredParams(t *testing.T) { - rule := initDifferentRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/digits.go b/rules/digits.go deleted file mode 100644 index 3edc0d9..0000000 --- a/rules/digits.go +++ /dev/null @@ -1,46 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Digits checks whether the field under validation has an exact number of digits. -// -// Usage: "digits:numberOfDigit". -// Example: "digits:5". -type Digits struct { - translation.BaseTranslatableRule - digitCount string -} - -// Validate checks if the value of the field under validation has an exact number of digits. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Digits) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(`^\d{`+r.digitCount+`}$`, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.digits", map[string]string{ - "field": selector, - "digitCount": r.digitCount, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Digits rule instance. -// The first parameter specifies the `numberOfDigit` to compare against (required). -func (r *Digits) AddParams(params []string) { - r.digitCount = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the Digits rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `numberOfDigit` parameter is mandatory. -func (*Digits) MinRequiredParams() int { - return 1 -} diff --git a/rules/digits_between.go b/rules/digits_between.go deleted file mode 100644 index 8089d5d..0000000 --- a/rules/digits_between.go +++ /dev/null @@ -1,49 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// DigitsBetween checks whether the field under validation has a length between the given min and max parameters. -// -// Usage: "digitsBetween:minDigits,maxDigits". -// Example: "digitsBetween:4,6". -type DigitsBetween struct { - translation.BaseTranslatableRule - min, max string -} - -// Validate checks if the value of the field under validation has a length between the given min and max parameters. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *DigitsBetween) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(`^\d{`+r.min+`,`+r.max+`}$`, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.digits_between", map[string]string{ - "field": selector, - "min": r.min, - "max": r.max, - })) - } - - return NewSuccessResult() -} - -// AddParams sets the minimum and maximum digit length constraints for the DigitsBetween rule. -// It takes two parameters: the first is assigned as the minimum required digit length, and the second as the maximum -// allowed digit length. No value is returned by this function. -func (r *DigitsBetween) AddParams(params []string) { - r.min = params[0] - r.max = params[1] -} - -// MinRequiredParams returns the minimum number of required parameters for the DigitsBetween rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `min` and `max` parameters are mandatory. -func (*DigitsBetween) MinRequiredParams() int { - return 2 -} diff --git a/rules/digits_between_test.go b/rules/digits_between_test.go deleted file mode 100644 index dbc41ad..0000000 --- a/rules/digits_between_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var digitsBetweenRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "46843", - }, - "params": []string{ - "4", "6", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedLessThanMinDigits": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "464", - }, - "params": []string{ - "4", "6", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field code must be between 4 and 6 digits.", - }, - }, -} - -func TestDigitsBetweenRule(t *testing.T) { - rule := initDigitsBetweenRule() - - for name, d := range digitsBetweenRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDigitsBetweenRule() *DigitsBetween { - digitsBetweenRule := &DigitsBetween{} - digitsBetweenRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.digits_between": - tr := "The field :field: must be between :min: and :max: digits." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return digitsBetweenRule -} - -func TestDigitsBetween_MinRequiredParams(t *testing.T) { - rule := initDigitsBetweenRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} diff --git a/rules/digits_test.go b/rules/digits_test.go deleted file mode 100644 index ab2f21b..0000000 --- a/rules/digits_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var digitsRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "468432", - }, - "params": []string{ - "6", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedUnequalDigits": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "46843", - }, - "params": []string{ - "6", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field code must have exactly 6 digits.", - }, - }, - "failedNotDigits": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "SIONDV", - }, - "params": []string{ - "6", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field code must have exactly 6 digits.", - }, - }, -} - -func TestDigitsRule(t *testing.T) { - rule := initDigitsRule() - - for name, d := range digitsRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDigitsRule() *Digits { - digitsRule := &Digits{} - digitsRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.digits": - tr := "The field :field: must have exactly :digitCount: digits." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return digitsRule -} - -func TestDigits_MinRequiredParams(t *testing.T) { - rule := initDigitsRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/distinct.go b/rules/distinct.go deleted file mode 100644 index 5b93be4..0000000 --- a/rules/distinct.go +++ /dev/null @@ -1,51 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Distinct checks whether the field under validation is an array/slice with all unique elements. -// This rule accepts no parameters. -// -// Usage: "distinct". -type Distinct struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is an array/slice with all unique elements. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Distinct) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - v := indirectValue(value) - if v.Kind() != reflect.Slice && v.Kind() != reflect.Array { - return NewSuccessResult() - } - - seen := make(map[any]struct{}) - for i := 0; i < v.Len(); i++ { - elem := v.Index(i).Interface() - // Only comparable keys can be used in a map; fallback to string for others - key := elem - if !v.Index(i).CanInterface() { - continue - } - if v.Index(i).CanAddr() && !v.Index(i).Type().Comparable() { - key = v.Index(i).Interface() - } - // Use reflect.Value for non-comparable types by converting to string - if _, ok := key.(interface{ String() string }); !ok && !v.Index(i).Type().Comparable() { - key = v.Index(i).String() - } - - if _, ok := seen[key]; ok { - return NewFailedResult(r.Translate(r.Locale, "validation.distinct", map[string]string{ - "field": selector, - })) - } - seen[key] = struct{}{} - } - - return NewSuccessResult() -} diff --git a/rules/distinct_test.go b/rules/distinct_test.go deleted file mode 100644 index 7405c0e..0000000 --- a/rules/distinct_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var distinctRuleTestData = map[string]any{ - "uniqueStrings": map[string]any{ - "input": map[string]any{ - "selector": "tags", - "inputBag": bag.InputBag{ - "tags": []string{"a", "b", "c"}, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "duplicates": map[string]any{ - "input": map[string]any{ - "selector": "tags", - "inputBag": bag.InputBag{ - "tags": []string{"a", "b", "a"}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field tags must not contain duplicate values.", - }, - }, - "nonSlice": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "john", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "emptySlice": map[string]any{ - "input": map[string]any{ - "selector": "nums", - "inputBag": bag.InputBag{ - "nums": []int{}, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "nonAdjacentDuplicates": map[string]any{ - "input": map[string]any{ - "selector": "nums", - "inputBag": bag.InputBag{ - "nums": []int{1, 2, 3, 2}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field nums must not contain duplicate values.", - }, - }, - "duplicatePointers": map[string]any{ - "input": map[string]any{ - "selector": "ptrs", - "inputBag": bag.InputBag{ - "ptrs": func() [](*int) { - a := 5 - b := 6 - pa := &a - pb := &b - return [](*int){pa, pb, pa} - }(), - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field ptrs must not contain duplicate values.", - }, - }, - "duplicateStructsComparable": map[string]any{ - "input": map[string]any{ - "selector": "items", - "inputBag": bag.InputBag{ - "items": []struct{ A int }{{A: 1}, {A: 2}, {A: 1}}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field items must not contain duplicate values.", - }, - }, - "duplicateNils": map[string]any{ - "input": map[string]any{ - "selector": "vals", - "inputBag": bag.InputBag{ - "vals": []any{nil, 1, nil}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field vals must not contain duplicate values.", - }, - }, - "nonComparableNoStringerDuplicates": map[string]any{ - "input": map[string]any{ - "selector": "items", - "inputBag": bag.InputBag{ - "items": func() []struct{ B []int } { - type s struct{ B []int } - return []struct{ B []int }{ - s{B: []int{3, 4}}, - s{B: []int{3, 4}}, - } - }(), - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field items must not contain duplicate values.", - }, - }, -} - -func TestDistinctRule(t *testing.T) { - rule := initDistinctRule() - - for name, d := range distinctRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initDistinctRule() *Distinct { - r := &Distinct{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.distinct": - tr := "The field :field: must not contain duplicate values." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return r -} diff --git a/rules/email.go b/rules/email.go deleted file mode 100644 index 0caa21b..0000000 --- a/rules/email.go +++ /dev/null @@ -1,94 +0,0 @@ -package rules - -import ( - "net" - "regexp" - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -const emailRegexPattern = "^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - -// Email checks if the field under validation is a valid email address based on RFC 5322. There is also an optional MX -// record check that can be enabled by passing `mx` as a parameter. -// -// Usage: "email[:mx]". -// Example: "email". -// Example: "email:mx". -type Email struct { - translation.BaseTranslatableRule - enableMXCheck bool - localPart string - domainPart string -} - -// Validate checks if the value of the field under validation is a valid email address based on RFC 5322. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Email) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if !r.isEmail(cast.ToString(value)) { - return NewFailedResult(r.Translate(r.Locale, "validation.email", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Email rule instance. -// The first parameter specifies the `mx` to compare against (optional). -func (r *Email) AddParams(params []string) { - for _, param := range params { - if param == "mx" { - r.enableMXCheck = true - return - } - } -} - -// MinRequiredParams returns the minimum number of required parameters for the Email rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 0, indicating that the `mx` parameter is optional. -func (*Email) MinRequiredParams() int { - return 0 -} - -func (r *Email) isEmail(emailAddress string) bool { - if !r.checkFormat(emailAddress) { - return false - } - - if r.enableMXCheck { - return r.checkMXRecord() - } - - return true -} - -func (r *Email) checkFormat(emailAddress string) bool { - parts := strings.Split(emailAddress, "@") - if len(parts) != 2 || len(parts[0]) > 64 || len(parts[1]) > 255 { - return false - } - - r.localPart = parts[0] - r.domainPart = parts[1] - - ok, err := regexp.MatchString(emailRegexPattern, emailAddress) - if !ok || err != nil { - return false - } - - return true -} - -func (r *Email) checkMXRecord() bool { - if _, err := net.LookupMX(r.domainPart); err != nil { - return false - } - - return true -} diff --git a/rules/email_test.go b/rules/email_test.go deleted file mode 100644 index 44f293c..0000000 --- a/rules/email_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var emailRuleTestData = map[string]any{ - "successfulFormat": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMXRecord": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@gmail.com", - }, - "params": []string{"mx"}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFormatIgnoredParam": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - }, - "params": []string{"ignored"}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedFormat": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "é&ààà@example.com", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email must be a valid email.", - }, - }, - "failedFormat2": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@something@example.com", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email must be a valid email.", - }, - }, - "failedFormat3": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "veryveryveryveryveryveryveryveryveryveryveryverylongusernameforanemail@example.com", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email must be a valid email.", - }, - }, - "failedMXRecord": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "admin@notarealdomain12345.com", - }, - "params": []string{"mx"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email must be a valid email.", - }, - }, -} - -func TestEmailRule(t *testing.T) { - rule := initEmailRule() - - for name, d := range emailRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initEmailRule() *Email { - emailRule := &Email{} - emailRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.email": - tr := "The field :field: must be a valid email." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return emailRule -} - -func TestEmail_MinRequiredParams(t *testing.T) { - rule := initEmailRule() - - assert.Equal(t, 0, rule.MinRequiredParams()) -} diff --git a/rules/ends_with.go b/rules/ends_with.go deleted file mode 100644 index d394cb4..0000000 --- a/rules/ends_with.go +++ /dev/null @@ -1,45 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// EndsWith checks whether the field under validation ends with the given substring. -// -// Usage: "endsWith:suffix". -// Example: "endsWith:Model". -type EndsWith struct { - translation.BaseTranslatableRule - suffix string -} - -// Validate checks if the value of the field under validation ends with the given substring. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *EndsWith) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if !strings.HasSuffix(cast.ToString(value), r.suffix) { - return NewFailedResult(r.Translate(r.Locale, "validation.ends_with", map[string]string{ - "field": selector, - "value": r.suffix, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the EndsWith rule instance. -// The first parameter specifies the `suffix` to compare against (required). -func (r *EndsWith) AddParams(params []string) { - r.suffix = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the EndsWith rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `suffix` parameter is mandatory. -func (*EndsWith) MinRequiredParams() int { - return 1 -} diff --git a/rules/ends_with_test.go b/rules/ends_with_test.go deleted file mode 100644 index 47371de..0000000 --- a/rules/ends_with_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var endsWithRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "className", - "inputBag": bag.InputBag{ - "className": "UserController", - }, - "params": []string{ - "Controller", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "className", - "inputBag": bag.InputBag{ - "className": "UserAction", - }, - "params": []string{ - "Controller", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field className must ends with Controller.", - }, - }, - "notString": map[string]any{ - "input": map[string]any{ - "selector": "className", - "inputBag": bag.InputBag{ - "className": map[string]any{ - "name": "UserController", - "path": "path/to/UserController.php", - }, - }, - "params": []string{ - "Controller", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field className must ends with Controller.", - }, - }, -} - -func TestEndsWithRule(t *testing.T) { - rule := initEndsWithRule() - - for name, d := range endsWithRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initEndsWithRule() *EndsWith { - endsWithRule := &EndsWith{} - endsWithRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.ends_with": - tr := "The field :field: must ends with :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return endsWithRule -} - -func TestEndsWith_MinRequiredParams(t *testing.T) { - rule := initEndsWithRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/gt.go b/rules/gt.go deleted file mode 100644 index 6633902..0000000 --- a/rules/gt.go +++ /dev/null @@ -1,63 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// GreaterThan checks whether the field under validation has a value or length greater than the specified value. -// -// Usage: "gt:value". -// Example: "gt:18". -type GreaterThan struct { - translation.BaseTranslatableRule - value float64 -} - -// Validate checks if the value of the field under validation has a value or length greater than the specified value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *GreaterThan) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var floatValue float64 - switch v.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - floatValue = cast.ToFloat64(value) - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - floatValue = float64(v.Len()) - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if floatValue <= r.value { - return NewFailedResult(r.Translate(r.Locale, "validation.gt", map[string]string{ - "field": selector, - "value": cast.ToString(r.value), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the GreaterThan rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *GreaterThan) AddParams(params []string) { - r.value = cast.ToFloat64(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the GreaterThan rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*GreaterThan) MinRequiredParams() int { - return 1 -} diff --git a/rules/gt_test.go b/rules/gt_test.go deleted file mode 100644 index d45c84d..0000000 --- a/rules/gt_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var greaterThanRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "80", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomethingUseful", - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExist": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{}, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherTypes": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 18, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have a value or length greater than 18.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "90", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must have a value or length greater than 90.", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "do", - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must have a value or length greater than 3.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go"}, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must have a value or length greater than 2.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must have a value or length greater than 2.", - }, - }, -} - -func TestGreaterThanRule(t *testing.T) { - rule := initGreaterThanRule() - - for name, d := range greaterThanRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initGreaterThanRule() *GreaterThan { - greaterThanRule := &GreaterThan{} - greaterThanRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.gt": - tr := "The field :field: must have a value or length greater than :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return greaterThanRule -} - -func TestGreaterThan_MinRequiredParams(t *testing.T) { - rule := initGreaterThanRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/gte.go b/rules/gte.go deleted file mode 100644 index 5ab0fdc..0000000 --- a/rules/gte.go +++ /dev/null @@ -1,63 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// GreaterThanEqual checks whether the field under validation has a value or length greater than or equal to the specified value. -// -// Usage: "gte:value". -// Example: "gte:18". -type GreaterThanEqual struct { - translation.BaseTranslatableRule - value float64 -} - -// Validate checks if the value of the field under validation has a value or length greater than or equal to the specified value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *GreaterThanEqual) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var floatValue float64 - switch v.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - floatValue = cast.ToFloat64(value) - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - floatValue = float64(v.Len()) - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if floatValue < r.value { - return NewFailedResult(r.Translate(r.Locale, "validation.gte", map[string]string{ - "field": selector, - "value": cast.ToString(r.value), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the GreaterThanEqual rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *GreaterThanEqual) AddParams(params []string) { - r.value = cast.ToFloat64(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the GreaterThanEqual rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*GreaterThanEqual) MinRequiredParams() int { - return 1 -} diff --git a/rules/gte_test.go b/rules/gte_test.go deleted file mode 100644 index aff4144..0000000 --- a/rules/gte_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var greaterThanEqualRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 18, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 80.01, - }, - "params": []string{ - "80", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "can", - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering"}, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExist": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{}, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherTypes": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 16, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have a value or length greater than or equal to 18.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "90", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must have a value or length greater than or equal to 90.", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "do", - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must have a value or length greater than or equal to 3.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go"}, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must have a value or length greater than or equal to 2.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must have a value or length greater than or equal to 2.", - }, - }, -} - -func TestGreaterThanEqualRule(t *testing.T) { - rule := initGreaterThanEqualRule() - - for name, d := range greaterThanEqualRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initGreaterThanEqualRule() *GreaterThanEqual { - greaterThanEqualRule := &GreaterThanEqual{} - greaterThanEqualRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.gte": - tr := "The field :field: must have a value or length greater than or equal to :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return greaterThanEqualRule -} - -func TestGreaterThanEqual_MinRequiredParams(t *testing.T) { - rule := initGreaterThanEqualRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/helpers.go b/rules/helpers.go deleted file mode 100644 index 7593127..0000000 --- a/rules/helpers.go +++ /dev/null @@ -1,16 +0,0 @@ -package rules - -import "reflect" - -func indirectValue(a any) reflect.Value { - v := reflect.ValueOf(a) - if v.Kind() != reflect.Pointer { - return v - } - - for v.Kind() == reflect.Pointer && !v.IsNil() { - v = v.Elem() - } - - return v -} diff --git a/rules/helpers_test.go b/rules/helpers_test.go deleted file mode 100644 index 584a0d7..0000000 --- a/rules/helpers_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package rules - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewResult(t *testing.T) { - res := NewResult(false, "failed") - - assert.True(t, res.Failed()) - assert.Equal(t, "failed", res.Message()) -} - -func TestIndirectValue(t *testing.T) { - s := "string" - data := struct { - S *string - }{ - S: &s, - } - - v := indirectValue(data.S) - assert.Equal(t, reflect.String, v.Kind()) -} diff --git a/rules/in.go b/rules/in.go deleted file mode 100644 index 10bad11..0000000 --- a/rules/in.go +++ /dev/null @@ -1,50 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// In checks whether the field under validation is in one of the given values. -// -// Usage: "in:value1,value2[,value3,...]. -// Example: "in:EUR,USD,GBP". -type In struct { - translation.BaseTranslatableRule - values []string -} - -// Validate checks if the value of the field under validation is in one of the given values. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *In) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strValue := strings.ToLower(cast.ToString(value)) - for _, val := range r.values { - val = strings.ToLower(val) - if strValue == val { - return NewSuccessResult() - } - } - - return NewFailedResult(r.Translate(r.Locale, "validation.in", map[string]string{ - "field": selector, - })) -} - -// AddParams assigns the provided parameter values to the In rule instance. -// The first parameter specifies the `value1` to compare against (required), -// and the second parameter, if provided, specifies the `value2` to compare against (optional), -// and so on. -func (r *In) AddParams(params []string) { - r.values = params -} - -// MinRequiredParams returns the minimum number of required parameters for the In rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `value1` and `value2` parameters are mandatory. -func (*In) MinRequiredParams() int { - return 2 -} diff --git a/rules/in_array_field.go b/rules/in_array_field.go deleted file mode 100644 index 15f637c..0000000 --- a/rules/in_array_field.go +++ /dev/null @@ -1,65 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// InArrayField checks whether the field under validation exists in another array/slice field. -// -// Usage: "inArrayField:otherField". -// Example: value selected from enum slice field. -type InArrayField struct { - translation.BaseTranslatableRule - otherField string -} - -// Validate checks if the value of the field under validation exists in another array/slice field. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *InArrayField) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - other, ok := inputBag.Get(r.otherField) - if !ok { - return NewFailedResult(r.Translate(r.Locale, "Validation.required", map[string]string{ - "otherField": r.otherField, - })) - } - - v := indirectValue(other) - if v.Len() == 0 { - return NewFailedResult(r.Translate(r.Locale, "validation.in", map[string]string{ - "field": selector, - })) - } - - strVal := toComparableString(value) - for i := 0; i < v.Len(); i++ { - if strings.EqualFold(strVal, toComparableString(v.Index(i).Interface())) { - return NewSuccessResult() - } - } - - return NewFailedResult(r.Translate(r.Locale, "validation.in", map[string]string{ - "field": selector, - })) -} - -// AddParams assigns the provided parameter values to the InArrayField rule instance. -// The first parameter specifies the `otherField` to compare against (required). -func (r *InArrayField) AddParams(params []string) { - r.otherField = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the InArrayField rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory. -func (*InArrayField) MinRequiredParams() int { return 1 } - -func toComparableString(v any) string { - s, ok := v.(string) - if ok { - return s - } - return indirectValue(v).String() -} diff --git a/rules/in_array_field_test.go b/rules/in_array_field_test.go deleted file mode 100644 index be6c69c..0000000 --- a/rules/in_array_field_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestInArrayFieldRule(t *testing.T) { - rule := initInArrayFieldRule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{ - "selector": "choice", - "inputBag": bag.InputBag{ - "choice": "b", - "options": []string{"a", "b", "c"}, - }, - "params": []string{"options"}, - }, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "notIn": map[string]any{ - "input": map[string]any{ - "selector": "choice", - "inputBag": bag.InputBag{ - "choice": "x", - "options": []string{"a", "b", "c"}, - }, - "params": []string{"options"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field choice must be in given values.", - }, - }, - "missingOther": map[string]any{ - "input": map[string]any{ - "selector": "choice", - "inputBag": bag.InputBag{ - "choice": "a", - }, - "params": []string{"options"}, - }, - "output": map[string]any{"validationFailed": true, "validationError": "The field options is required."}, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - rule.AddParams(input["params"].([]string)) - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initInArrayFieldRule() *InArrayField { - r := &InArrayField{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.in": - tr := "The field :field: must be in given values." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - case "Validation.required": - tr := "The field :otherField: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/in_test.go b/rules/in_test.go deleted file mode 100644 index 11e6845..0000000 --- a/rules/in_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var inRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "currency", - "inputBag": bag.InputBag{ - "currency": "USD", - }, - "params": []string{ - "EUR", "USD", "GBP", "JPY", "CHF", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "currency", - "inputBag": bag.InputBag{ - "currency": "HKD", - }, - "params": []string{ - "EUR", "USD", "GBP", "JPY", "CHF", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The selected currency is invalid.", - }, - }, -} - -func TestInRule(t *testing.T) { - rule := initInRule() - - for name, d := range inRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initInRule() *In { - inRule := &In{} - inRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.in": - tr := "The selected :field: is invalid." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return inRule -} - -func TestIn_MinRequiredParams(t *testing.T) { - rule := initInRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} diff --git a/rules/integer.go b/rules/integer.go deleted file mode 100644 index e72a05a..0000000 --- a/rules/integer.go +++ /dev/null @@ -1,35 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Integer checks whether the field under validation has an integer value. -// This rule accepts no parameters. -// -// Usage: "integer". -type Integer struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation has an integer value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Integer) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - switch v.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return NewSuccessResult() - default: - return NewFailedResult(r.Translate(r.Locale, "validation.integer", map[string]string{ - "field": selector, - })) - } -} diff --git a/rules/integer_test.go b/rules/integer_test.go deleted file mode 100644 index 4048a18..0000000 --- a/rules/integer_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var integerRuleTestData = map[string]any{ - "successfulInt": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExists": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25.9, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have an integer value.", - }, - }, - "failedNumericString": map[string]any{ - "input": map[string]any{ - "selector": "year", - "inputBag": bag.InputBag{ - "year": "1989", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field year must have an integer value.", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have an integer value.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": []string{"John", "Doe"}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have an integer value.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": map[string]any{ - "username": "johnDoe", - "name": "John Doe", - }, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have an integer value.", - }, - }, -} - -func TestIntegerRule(t *testing.T) { - rule := initIntegerRule() - - for name, d := range integerRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initIntegerRule() *Integer { - integerRule := &Integer{} - integerRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.integer": - tr := "The field :field: must have an integer value." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return integerRule -} diff --git a/rules/ip.go b/rules/ip.go deleted file mode 100644 index 8415d99..0000000 --- a/rules/ip.go +++ /dev/null @@ -1,30 +0,0 @@ -package rules - -import ( - "net" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// IP checks whether the field under validation is a valid IP address (v4 or v6). -// This rule accepts no parameters. -// -// Usage: "ip". -type IP struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid IP address (v4 or v6). -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *IP) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s := cast.ToString(value) - if net.ParseIP(s) == nil { - return NewFailedResult(r.Translate(r.Locale, "validation.ip", map[string]string{ - "field": selector, - })) - } - return NewSuccessResult() -} diff --git a/rules/ip_test.go b/rules/ip_test.go deleted file mode 100644 index b2ed97e..0000000 --- a/rules/ip_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var ipRuleTestData = map[string]any{ - "okIPv4": map[string]any{ - "input": map[string]any{ - "selector": "addr", - "inputBag": bag.InputBag{ - "addr": "192.168.1.1", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "okIPv6": map[string]any{ - "input": map[string]any{ - "selector": "addr", - "inputBag": bag.InputBag{ - "addr": "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "invalid": map[string]any{ - "input": map[string]any{ - "selector": "addr", - "inputBag": bag.InputBag{ - "addr": "999.999.999.999", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field addr must be a valid ip.", - }, - }, - "empty": map[string]any{ - "input": map[string]any{ - "selector": "addr", - "inputBag": bag.InputBag{ - "addr": "", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field addr must be a valid ip.", - }, - }, -} - -func TestIPRule(t *testing.T) { - for name, d := range ipRuleTestData { - t.Run(name, func(t *testing.T) { - rule := initIPRule() - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initIPRule() *IP { - r := &IP{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.ip": - tr := "The field :field: must be a valid ip." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/ipv4.go b/rules/ipv4.go deleted file mode 100644 index 2587007..0000000 --- a/rules/ipv4.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "net" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// IPv4 checks the field under validation is a valid IPv4 address. -// This rule accepts no parameters. -// -// Usage: "ipv4". -type IPv4 struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid IPv4 address. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *IPv4) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s := cast.ToString(value) - ip := net.ParseIP(s) - if ip == nil || ip.To4() == nil { - return NewFailedResult(r.Translate(r.Locale, "validation.ipv4", map[string]string{ - "field": selector, - })) - } - return NewSuccessResult() -} diff --git a/rules/ipv4_test.go b/rules/ipv4_test.go deleted file mode 100644 index bc2934a..0000000 --- a/rules/ipv4_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestIPv4Rule(t *testing.T) { - rule := initIPv4Rule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{"selector": "addr", "inputBag": bag.InputBag{"addr": "8.8.8.8"}}, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalidV6": map[string]any{ - "input": map[string]any{"selector": "addr", "inputBag": bag.InputBag{"addr": "2001:db8::1"}}, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field addr must be a valid ipv4.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initIPv4Rule() *IPv4 { - r := &IPv4{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.ipv4": - tr := "The field :field: must be a valid ipv4." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/ipv6.go b/rules/ipv6.go deleted file mode 100644 index bd449c1..0000000 --- a/rules/ipv6.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "net" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// IPv6 checks whether the field under validation is a valid IPv6 address. -// This rule accepts no parameters. -// -// Usage: "ipv6". -type IPv6 struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid IPv6 address. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *IPv6) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s := cast.ToString(value) - ip := net.ParseIP(s) - if ip == nil || ip.To4() != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.ipv6", map[string]string{ - "field": selector, - })) - } - return NewSuccessResult() -} diff --git a/rules/ipv6_test.go b/rules/ipv6_test.go deleted file mode 100644 index 2db67c2..0000000 --- a/rules/ipv6_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestIPv6Rule(t *testing.T) { - rule := initIPv6Rule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{"selector": "addr", "inputBag": bag.InputBag{"addr": "2001:db8::1"}}, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalidV4": map[string]any{ - "input": map[string]any{"selector": "addr", "inputBag": bag.InputBag{"addr": "127.0.0.1"}}, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field addr must be a valid ipv6.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initIPv6Rule() *IPv6 { - r := &IPv6{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.ipv6": - tr := "The field :field: must be a valid ipv6." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/length.go b/rules/length.go deleted file mode 100644 index ce91a01..0000000 --- a/rules/length.go +++ /dev/null @@ -1,59 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Length checks whether the field under validation has an exact given length. -// -// Usage: "length:value". -// Example: "length:10". -type Length struct { - translation.BaseTranslatableRule - expectedLength int -} - -// Validate checks if the value of the field under validation has an exact given length. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Length) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var length int - switch v.Kind() { - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - length = v.Len() - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if length != r.expectedLength { - return NewFailedResult(r.Translate(r.Locale, "validation.length", map[string]string{ - "field": selector, - "value": cast.ToString(r.expectedLength), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Length rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *Length) AddParams(params []string) { - r.expectedLength = cast.ToInt(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the Length rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*Length) MinRequiredParams() int { - return 1 -} diff --git a/rules/length_test.go b/rules/length_test.go deleted file mode 100644 index 37bc39d..0000000 --- a/rules/length_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var lengthRuleTestData = map[string]any{ - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "11", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "4", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExists": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{}, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherType": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "10", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomethingUseful", - }, - "params": []string{ - "10", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must have exact length of 10.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must have exact length of 5.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must have exact length of 2.", - }, - }, -} - -func TestLengthRule(t *testing.T) { - rule := initLengthRule() - - for name, d := range lengthRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initLengthRule() *Length { - lengthRule := &Length{} - lengthRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.length": - tr := "The field :field: must have exact length of :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return lengthRule -} - -func TestLength_MinRequiredParams(t *testing.T) { - rule := initLengthRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/lowercase.go b/rules/lowercase.go deleted file mode 100644 index 9d26c85..0000000 --- a/rules/lowercase.go +++ /dev/null @@ -1,32 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Lowercase checks whether the field under validation is a lowercase string. -// This rule accepts no parameters. -// -// Usage: "lowercase". -type Lowercase struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a lowercase string. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Lowercase) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strValue := cast.ToString(value) - - if strValue != strings.ToLower(strValue) { - return NewFailedResult(r.Translate(r.Locale, "validation.lowercase", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/lowercase_test.go b/rules/lowercase_test.go deleted file mode 100644 index 86494e6..0000000 --- a/rules/lowercase_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var lowercaseRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "john", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notLowercase": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must be lowercase.", - }, - }, -} - -func TestLowercaseRule(t *testing.T) { - rule := initLowercaseRule() - - for name, d := range lowercaseRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initLowercaseRule() *Lowercase { - lowercaseRule := &Lowercase{} - lowercaseRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.lowercase": - tr := "The field :field: must be lowercase." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return lowercaseRule -} diff --git a/rules/lt.go b/rules/lt.go deleted file mode 100644 index 8e62452..0000000 --- a/rules/lt.go +++ /dev/null @@ -1,63 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// LessThan checks whether the field under validation has a value or a length less than the given value. -// -// Usage: "lt:value". -// Example: "lt:18". -type LessThan struct { - translation.BaseTranslatableRule - value float64 -} - -// Validate checks if the value of the field under validation has a value or a length less than the given value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *LessThan) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var floatValue float64 - switch v.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - floatValue = cast.ToFloat64(value) - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - floatValue = float64(v.Len()) - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if floatValue >= r.value { - return NewFailedResult(r.Translate(r.Locale, "validation.lt", map[string]string{ - "field": selector, - "value": cast.ToString(r.value), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the LessThan rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *LessThan) AddParams(params []string) { - r.value = cast.ToFloat64(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the LessThan rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*LessThan) MinRequiredParams() int { - return 1 -} diff --git a/rules/lt_test.go b/rules/lt_test.go deleted file mode 100644 index abd5725..0000000 --- a/rules/lt_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var lessThanRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 16, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 35.58, - }, - "params": []string{ - "80", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "15", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExist": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{}, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherTypes": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 18, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have a value or length less than 18.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "80.50", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must have a value or length less than 80.5.", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "10", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must have a value or length less than 10.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must have a value or length less than 3.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must have a value or length less than 2.", - }, - }, -} - -func TestLessThanRule(t *testing.T) { - rule := initLessThanRule() - - for name, d := range lessThanRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initLessThanRule() *LessThan { - lessThanRule := &LessThan{} - lessThanRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.lt": - tr := "The field :field: must have a value or length less than :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return lessThanRule -} - -func TestLessThan_MinRequiredParams(t *testing.T) { - rule := initLessThanRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/lte.go b/rules/lte.go deleted file mode 100644 index 4be8f2d..0000000 --- a/rules/lte.go +++ /dev/null @@ -1,63 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// LessThanEqual checks whether the field under validation has a value or a length less than the given value. -// -// Usage: "lte:value". -// Example: "lte:18". -type LessThanEqual struct { - translation.BaseTranslatableRule - value float64 -} - -// Validate checks if the value of the field under validation has a value or a length less than the given value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *LessThanEqual) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var floatValue float64 - switch v.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - floatValue = cast.ToFloat64(value) - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - floatValue = float64(v.Len()) - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if floatValue > r.value { - return NewFailedResult(r.Translate(r.Locale, "validation.lte", map[string]string{ - "field": selector, - "value": cast.ToString(r.value), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the LessThanEqual rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *LessThanEqual) AddParams(params []string) { - r.value = cast.ToFloat64(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the LessThanEqual rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*LessThanEqual) MinRequiredParams() int { - return 1 -} diff --git a/rules/lte_test.go b/rules/lte_test.go deleted file mode 100644 index 2ff653b..0000000 --- a/rules/lte_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var lessThanEqualRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 18, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 79.99, - }, - "params": []string{ - "80", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "12", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "4", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExist": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{}, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 19, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must have a value or length less than or equal to 18.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "80.50", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must have a value or length less than or equal to 80.5.", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "10", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must have a value or length less than or equal to 10.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must have a value or length less than or equal to 3.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must have a value or length less than or equal to 2.", - }, - }, - "successfulOtherTypes": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, -} - -func TestLessThanEqualRule(t *testing.T) { - rule := initLessThanEqualRule() - - for name, d := range lessThanEqualRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initLessThanEqualRule() *LessThanEqual { - lessThanEqualRule := &LessThanEqual{} - lessThanEqualRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.lte": - tr := "The field :field: must have a value or length less than or equal to :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return lessThanEqualRule -} - -func TestLessThanEqual_MinRequiredParams(t *testing.T) { - rule := initLessThanEqualRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/mac_address.go b/rules/mac_address.go deleted file mode 100644 index 89b12ef..0000000 --- a/rules/mac_address.go +++ /dev/null @@ -1,33 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -var macRegex = regexp.MustCompile(`^(?i)([0-9A-F]{2}([-:])){5}([0-9A-F]{2})$`) - -// MacAddress checks whether the field under validation is a valid MAC address. -// Supports formats like 01:23:45:67:89:ab or 01-23-45-67-89-ab. -// This rule accepts no parameters. -// -// Usage: "macAddress". -type MacAddress struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid MAC address. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *MacAddress) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - s := cast.ToString(value) - if !macRegex.MatchString(s) { - return NewFailedResult(r.Translate(r.Locale, "validation.mac_address", map[string]string{ - "field": selector, - })) - } - return NewSuccessResult() -} diff --git a/rules/mac_address_test.go b/rules/mac_address_test.go deleted file mode 100644 index c13615f..0000000 --- a/rules/mac_address_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestMacAddressRule(t *testing.T) { - rule := initMacRule() - - tests := map[string]any{ - "okColon": map[string]any{ - "input": map[string]any{"selector": "mac", "inputBag": bag.InputBag{"mac": "01:23:45:67:89:ab"}}, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "okDash": map[string]any{ - "input": map[string]any{"selector": "mac", "inputBag": bag.InputBag{"mac": "01-23-45-67-89-AB"}}, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalid": map[string]any{ - "input": map[string]any{"selector": "mac", "inputBag": bag.InputBag{"mac": "0123.4567.89ab"}}, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field mac must be a valid mac address.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMacRule() *MacAddress { - r := &MacAddress{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.mac_address": - tr := "The field :field: must be a valid mac address." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/max.go b/rules/max.go deleted file mode 100644 index 7c1ef36..0000000 --- a/rules/max.go +++ /dev/null @@ -1,43 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Max checks whether the field under validation value be less than given value. -// -// Usage: "max:value". -// Example: "max:10". -type Max struct { - translation.BaseTranslatableRule - max float64 -} - -// Validate checks if the value of the field under validation value be less than given value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Max) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if cast.ToFloat64(value) > r.max { - return NewFailedResult(r.Translate(r.Locale, "validation.max", map[string]string{ - "field": selector, - "value": cast.ToString(r.max), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Max rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *Max) AddParams(params []string) { - r.max = cast.ToFloat64(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the Max rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*Max) MinRequiredParams() int { - return 1 -} diff --git a/rules/max_digits.go b/rules/max_digits.go deleted file mode 100644 index c2c861a..0000000 --- a/rules/max_digits.go +++ /dev/null @@ -1,48 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// MaxDigits checks whether the field under validation has length less than given max digits. -// -// Usage: "maxDigits:numberOfDigit". -// Example: "maxDigits:5". -type MaxDigits struct { - translation.BaseTranslatableRule - digitCount string -} - -// Validate checks if the value of the field under validation has length less than given max digits. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *MaxDigits) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strVal := cast.ToString(value) - - ok, err := regexp.MatchString(`^\d{1,`+r.digitCount+`}$`, strVal) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.max_digits", map[string]string{ - "field": selector, - "digitCount": r.digitCount, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the MaxDigits rule instance. -// The first parameter specifies the `numberOfDigit` to compare against (required). -func (r *MaxDigits) AddParams(params []string) { - r.digitCount = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the MaxDigits rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `numberOfDigit` parameter is mandatory. -func (*MaxDigits) MinRequiredParams() int { - return 1 -} diff --git a/rules/max_digits_test.go b/rules/max_digits_test.go deleted file mode 100644 index 392539a..0000000 --- a/rules/max_digits_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var maxDigitsRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "46843", - }, - "params": []string{ - "6", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedReachedMaxDigits": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "468439", - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field code must not have more than 5 digits.", - }, - }, -} - -func TestMaxDigitsRule(t *testing.T) { - rule := initMaxDigitsRule() - - for name, d := range maxDigitsRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMaxDigitsRule() *MaxDigits { - maxDigitsRule := &MaxDigits{} - maxDigitsRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.max_digits": - tr := "The field :field: must not have more than :digitCount: digits." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return maxDigitsRule -} - -func TestMaxDigits_MinRequiredParams(t *testing.T) { - rule := initMaxDigitsRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/max_length.go b/rules/max_length.go deleted file mode 100644 index a496dd3..0000000 --- a/rules/max_length.go +++ /dev/null @@ -1,59 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// MaxLength checks whether the field under validation length did not reach given length. -// -// Usage: "maxLength:value". -// Example: "maxLength:10". -type MaxLength struct { - translation.BaseTranslatableRule - maxLength int -} - -// Validate checks if the value of the field under validation length did not reach given length. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *MaxLength) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var length int - switch v.Kind() { - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - length = v.Len() - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if length > r.maxLength { - return NewFailedResult(r.Translate(r.Locale, "validation.max_length", map[string]string{ - "field": selector, - "value": cast.ToString(r.maxLength), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the MaxLength rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *MaxLength) AddParams(params []string) { - r.maxLength = cast.ToInt(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the MaxLength rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*MaxLength) MinRequiredParams() int { - return 1 -} diff --git a/rules/max_length_test.go b/rules/max_length_test.go deleted file mode 100644 index 43c0d3a..0000000 --- a/rules/max_length_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var maxLengthRuleTestData = map[string]any{ - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "15", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExists": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{}, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherType": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomethingUseful", - }, - "params": []string{ - "10", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must not have a length more than 10.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must not have a length more than 3.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "2", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must not have a length more than 2.", - }, - }, -} - -func TestMaxLengthRule(t *testing.T) { - rule := initMaxLengthRule() - - for name, d := range maxLengthRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMaxLengthRule() *MaxLength { - maxLengthRule := &MaxLength{} - maxLengthRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.max_length": - tr := "The field :field: must not have a length more than :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return maxLengthRule -} - -func TestMaxLength_MinRequiredParams(t *testing.T) { - rule := initMaxLengthRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/max_test.go b/rules/max_test.go deleted file mode 100644 index 9658dfe..0000000 --- a/rules/max_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var maxRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "30", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "100", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must not have a value more than 18.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "50", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must not have a value more than 50.", - }, - }, -} - -func TestMaxRule(t *testing.T) { - rule := initMaxRule() - - for name, d := range maxRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMaxRule() *Max { - maxRule := &Max{} - maxRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.max": - tr := "The field :field: must not have a value more than :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return maxRule -} - -func TestMax_MinRequiredParams(t *testing.T) { - rule := initMaxRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/min.go b/rules/min.go deleted file mode 100644 index 35648ec..0000000 --- a/rules/min.go +++ /dev/null @@ -1,45 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Min checks whether the field under validation value be greater than given value. -// -// Usage: "min:value". -// Example: "min:10". -type Min struct { - translation.BaseTranslatableRule - min float64 -} - -// Validate checks if the value of the field under validation value be greater than given value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Min) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - floatValue := cast.ToFloat64(value) - - if floatValue < r.min { - return NewFailedResult(r.Translate(r.Locale, "validation.min", map[string]string{ - "field": selector, - "value": cast.ToString(r.min), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Min rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *Min) AddParams(params []string) { - r.min = cast.ToFloat64(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the Min rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*Min) MinRequiredParams() int { - return 1 -} diff --git a/rules/min_digits.go b/rules/min_digits.go deleted file mode 100644 index 1a240f7..0000000 --- a/rules/min_digits.go +++ /dev/null @@ -1,48 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// MinDigits checks whether the field under validation has length more than given min digits. -// -// Usage: "minDigits:numberOfDigit". -// Example: "minDigits:5". -type MinDigits struct { - translation.BaseTranslatableRule - digitCount string -} - -// Validate checks if the value of the field under validation has length more than given min digits. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *MinDigits) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strVal := cast.ToString(value) - - ok, err := regexp.MatchString(`^\d{`+r.digitCount+`,}$`, strVal) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.min_digits", map[string]string{ - "field": selector, - "digitCount": r.digitCount, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the MinDigits rule instance. -// The first parameter specifies the `numberOfDigit` to compare against (required). -func (r *MinDigits) AddParams(params []string) { - r.digitCount = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the MinDigits rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `numberOfDigit` parameter is mandatory. -func (*MinDigits) MinRequiredParams() int { - return 1 -} diff --git a/rules/min_digits_test.go b/rules/min_digits_test.go deleted file mode 100644 index c05d456..0000000 --- a/rules/min_digits_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var minDigitsRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "46843", - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedLessThanMinDigits": map[string]any{ - "input": map[string]any{ - "selector": "code", - "inputBag": bag.InputBag{ - "code": "4684", - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field code must not have less than 5 digits.", - }, - }, -} - -func TestMinDigitsRule(t *testing.T) { - rule := initMinDigitsRule() - - for name, d := range minDigitsRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMinDigitsRule() *MinDigits { - minDigitsRule := &MinDigits{} - minDigitsRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.min_digits": - tr := "The field :field: must not have less than :digitCount: digits." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return minDigitsRule -} - -func TestMinDigits_MinRequiredParams(t *testing.T) { - rule := initMinDigitsRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/min_length.go b/rules/min_length.go deleted file mode 100644 index 819dad8..0000000 --- a/rules/min_length.go +++ /dev/null @@ -1,59 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// MinLength checks whether the field under validation length be greater than given length. -// -// Usage: "minLength:value". -// Example: "minLength:10". -type MinLength struct { - translation.BaseTranslatableRule - minLength int -} - -// Validate checks if the value of the field under validation length be greater than given length. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *MinLength) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - var length int - switch v.Kind() { - case reflect.String, reflect.Slice, reflect.Array, reflect.Map: - length = v.Len() - default: - // ignore the rule if not match any of the specified types. - return NewSuccessResult() - } - - if length < r.minLength { - return NewFailedResult(r.Translate(r.Locale, "validation.min_length", map[string]string{ - "field": selector, - "value": cast.ToString(r.minLength), - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the MinLength rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *MinLength) AddParams(params []string) { - r.minLength = cast.ToInt(params[0]) -} - -// MinRequiredParams returns the minimum number of required parameters for the MinLength rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*MinLength) MinRequiredParams() int { - return 1 -} diff --git a/rules/min_length_test.go b/rules/min_length_test.go deleted file mode 100644 index c4a151f..0000000 --- a/rules/min_length_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var minLengthRuleTestData = map[string]any{ - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "doSomething", - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "1", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExists": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{}, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulOtherType": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": struct{}{}, - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "functionName", - "inputBag": bag.InputBag{ - "functionName": "do", - }, - "params": []string{ - "3", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field functionName must not have a length less than 3.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{"Go", "Software Engineering", "TDD", "Software Architecture"}, - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must not have a length less than 5.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "user", - "inputBag": bag.InputBag{ - "user": map[string]any{ - "userName": "johnDoe", - "name": "John Doe", - "age": 35, - }, - }, - "params": []string{ - "5", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field user must not have a length less than 5.", - }, - }, -} - -func TestMinLengthRule(t *testing.T) { - rule := initMinLengthRule() - - for name, d := range minLengthRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMinLengthRule() *MinLength { - minLengthRule := &MinLength{} - minLengthRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.min_length": - tr := "The field :field: must not have a length less than :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return minLengthRule -} - -func TestMinLength_MinRequiredParams(t *testing.T) { - rule := initMinLengthRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/min_test.go b/rules/min_test.go deleted file mode 100644 index 4faf4ea..0000000 --- a/rules/min_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var minRuleTestData = map[string]any{ - "successfulInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "18", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "80", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - "params": []string{ - "30", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must not have a value less than 30.", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 86.58, - }, - "params": []string{ - "90", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field score must not have a value less than 90.", - }, - }, -} - -func TestMinRule(t *testing.T) { - rule := initMinRule() - - for name, d := range minRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initMinRule() *Min { - minRule := &Min{} - minRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.min": - tr := "The field :field: must not have a value less than :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return minRule -} - -func TestMin_MinRequiredParams(t *testing.T) { - rule := initMinRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/neq.go b/rules/neq.go deleted file mode 100644 index b07128a..0000000 --- a/rules/neq.go +++ /dev/null @@ -1,43 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// NotEqual checks whether the field under validation is not equal to given value. -// -// Usage: "neq:value". -// Example: "neq:admin". -type NotEqual struct { - translation.BaseTranslatableRule - value string -} - -// Validate checks if the value of the field under validation is not equal to given value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *NotEqual) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if cast.ToString(value) == r.value { - return NewFailedResult(r.Translate(r.Locale, "validation.neq", map[string]string{ - "field": selector, - "value": r.value, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the NotEqual rule instance. -// The first parameter specifies the `value` to compare against (required). -func (r *NotEqual) AddParams(params []string) { - r.value = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the NotEqual rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `value` parameter is mandatory. -func (*NotEqual) MinRequiredParams() int { - return 1 -} diff --git a/rules/neq_test.go b/rules/neq_test.go deleted file mode 100644 index c72edf2..0000000 --- a/rules/neq_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var notEqualRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "username", - "inputBag": bag.InputBag{ - "username": "goodUser", - }, - "params": []string{ - "admin", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "username", - "inputBag": bag.InputBag{ - "username": "admin", - }, - "params": []string{ - "admin", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field username could not be admin.", - }, - }, -} - -func TestNotEqualRule(t *testing.T) { - rule := initNotEqualRule() - - for name, d := range notEqualRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initNotEqualRule() *NotEqual { - notEqualRule := &NotEqual{} - notEqualRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.neq": - tr := "The field :field: could not be :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return notEqualRule -} - -func TestNotEqual_MinRequiredParams(t *testing.T) { - rule := initNotEqualRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/not_empty.go b/rules/not_empty.go deleted file mode 100644 index 809e20d..0000000 --- a/rules/not_empty.go +++ /dev/null @@ -1,28 +0,0 @@ -package rules - -import ( - "github.com/thoas/go-funk" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// NotEmpty checks whether the field under validation be a non-empty or non-zero value. -// This rule accepts no parameters. -// -// Usage: "notEmpty". -type NotEmpty struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation be a non-empty or non-zero value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *NotEmpty) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if funk.IsEmpty(value) { - return NewFailedResult(r.Translate(r.Locale, "validation.not_empty", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/not_empty_test.go b/rules/not_empty_test.go deleted file mode 100644 index 5994e18..0000000 --- a/rules/not_empty_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var notEmptyRuleTestData = map[string]any{ - "emptyString": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must not be empty.", - }, - }, - "nilValue": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": nil, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must not be empty.", - }, - }, - "emptySlice": map[string]any{ - "input": map[string]any{ - "selector": "skills", - "inputBag": bag.InputBag{ - "skills": []string{}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field skills must not be empty.", - }, - }, - "emptyBoolean": map[string]any{ - "input": map[string]any{ - "selector": "agreed", - "inputBag": bag.InputBag{ - "agreed": false, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field agreed must not be empty.", - }, - }, - "notEmpty": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, -} - -func TestNotEmptyRule(t *testing.T) { - rule := initNotEmptyRule() - - for name, d := range notEmptyRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initNotEmptyRule() *NotEmpty { - notEmptyRule := &NotEmpty{} - notEmptyRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.not_empty": - tr := "The field :field: must not be empty." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return notEmptyRule -} diff --git a/rules/not_in.go b/rules/not_in.go deleted file mode 100644 index bc5435f..0000000 --- a/rules/not_in.go +++ /dev/null @@ -1,47 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// NotIn checks whether the field under validation not be in one of given values. -// -// Usage: "notIn:value1,value2[,value3,...]". -// Example: "notIn:admin,superuser". -type NotIn struct { - translation.BaseTranslatableRule - values []string -} - -// Validate checks if the value of the field under validation not be in one of given values. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *NotIn) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strValue := cast.ToString(value) - for _, val := range r.values { - if strValue == val { - return NewFailedResult(r.Translate(r.Locale, "validation.not_in", map[string]string{ - "field": selector, - })) - } - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the NotIn rule instance. -// The first parameter specifies the `value1` to compare against (required), -// and the second parameter, if provided, specifies the `value2` to compare against (optional), -// and so on. -func (r *NotIn) AddParams(params []string) { - r.values = params -} - -// MinRequiredParams returns the minimum number of required parameters for the NotIn rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `value1` and `value2` parameters are mandatory. -func (*NotIn) MinRequiredParams() int { - return 2 -} diff --git a/rules/not_in_test.go b/rules/not_in_test.go deleted file mode 100644 index 99312d2..0000000 --- a/rules/not_in_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var notInRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "username", - "inputBag": bag.InputBag{ - "username": "goodUser", - }, - "params": []string{ - "admin", "superuser", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "username", - "inputBag": bag.InputBag{ - "username": "admin", - }, - "params": []string{ - "admin", "superuser", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The selected username is invalid.", - }, - }, -} - -func TestNotInRule(t *testing.T) { - rule := initNotInRule() - - for name, d := range notInRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initNotInRule() *NotIn { - notInRule := &NotIn{} - notInRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.not_in": - tr := "The selected :field: is invalid." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return notInRule -} - -func TestNotIn_MinRequiredParams(t *testing.T) { - rule := initNotInRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} diff --git a/rules/not_regex.go b/rules/not_regex.go deleted file mode 100644 index e293e1f..0000000 --- a/rules/not_regex.go +++ /dev/null @@ -1,46 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// NotRegex checks whether the field under validation does not match the given regex pattern. -// -// Usage: "notRegex:pattern". -// Example: "notRegex:[a-zA-Z0-9]+". -type NotRegex struct { - translation.BaseTranslatableRule - pattern string -} - -// Validate checks if the value of the field under validation does not match the given regex pattern. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *NotRegex) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(r.pattern, cast.ToString(value)) - if ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.not_regex", map[string]string{ - "field": selector, - "pattern": r.pattern, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the NotRegex rule instance. -// The first parameter specifies the `pattern` to compare against (required). -func (r *NotRegex) AddParams(params []string) { - r.pattern = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the NotRegex rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `pattern` parameter is mandatory. -func (*NotRegex) MinRequiredParams() int { - return 1 -} diff --git a/rules/not_regex_test.go b/rules/not_regex_test.go deleted file mode 100644 index 863a1e2..0000000 --- a/rules/not_regex_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var notRegexRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "variableName", - "inputBag": bag.InputBag{ - "variableName": "user_name", - }, - "params": []string{ - "^[a-zA-Z0-9]+$", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "variableName", - "inputBag": bag.InputBag{ - "variableName": "userName", - }, - "params": []string{ - "^[a-zA-Z0-9]+$", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field variableName must not match the regex pattern ^[a-zA-Z0-9]+$.", - }, - }, - "failedNotString": map[string]any{ - "input": map[string]any{ - "selector": "variableName", - "inputBag": bag.InputBag{ - "variableName": map[string]any{ - "name": "userName", - }, - }, - "params": []string{ - "^[a-zA-Z0-9]+$", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, -} - -func TestNotRegexRule(t *testing.T) { - rule := initNotRegexRule() - - for name, d := range notRegexRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initNotRegexRule() *NotRegex { - notRegexRule := &NotRegex{} - notRegexRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.not_regex": - tr := "The field :field: must not match the regex pattern :pattern:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "validation.string": - tr := "The field :field: must have an string value." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return notRegexRule -} - -func TestNotRegex_MinRequiredParams(t *testing.T) { - rule := initNotRegexRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/numeric.go b/rules/numeric.go deleted file mode 100644 index e05c355..0000000 --- a/rules/numeric.go +++ /dev/null @@ -1,29 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Numeric checks whether the field under validation has a numeric value. -// This rule accepts no parameters. -// -// Usage: "numeric". -type Numeric struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation has a numeric value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Numeric) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - _, err := cast.ToFloat64E(value) - if err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.numeric", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/numeric_test.go b/rules/numeric_test.go deleted file mode 100644 index 9bfcad7..0000000 --- a/rules/numeric_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var numericRuleTestData = map[string]any{ - "successfulInt": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": 25, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulFloat": map[string]any{ - "input": map[string]any{ - "selector": "score", - "inputBag": bag.InputBag{ - "score": 80.65, - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNumericString": map[string]any{ - "input": map[string]any{ - "selector": "year", - "inputBag": bag.InputBag{ - "year": "1989", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedString": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must be a number.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": []string{"John", "Doe"}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must be a number.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "age": map[string]any{ - "username": "johnDoe", - "name": "John Doe", - }, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age must be a number.", - }, - }, -} - -func TestNumericRule(t *testing.T) { - rule := initNumericRule() - - for name, d := range numericRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initNumericRule() *Numeric { - numericRule := &Numeric{} - numericRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.numeric": - tr := "The field :field: must be a number." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return numericRule -} diff --git a/rules/regex.go b/rules/regex.go deleted file mode 100644 index 954596d..0000000 --- a/rules/regex.go +++ /dev/null @@ -1,46 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Regex checks whether the field under validation match the given regex pattern. -// -// Usage: "regex:pattern". -// Example: "regex:[a-zA-Z0-9]+". -type Regex struct { - translation.BaseTranslatableRule - pattern string -} - -// Validate checks if the value of the field under validation match the given regex pattern. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Regex) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - ok, err := regexp.MatchString(r.pattern, cast.ToString(value)) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.regex", map[string]string{ - "field": selector, - "pattern": r.pattern, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the Regex rule instance. -// The first parameter specifies the `pattern` to compare against (required). -func (r *Regex) AddParams(params []string) { - r.pattern = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the Regex rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `pattern` parameter is mandatory. -func (*Regex) MinRequiredParams() int { - return 1 -} diff --git a/rules/regex_test.go b/rules/regex_test.go deleted file mode 100644 index 8fb092e..0000000 --- a/rules/regex_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var regexRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "variableName", - "inputBag": bag.InputBag{ - "variableName": "userName", - }, - "params": []string{ - "^[a-zA-Z0-9]+$", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "variableName", - "inputBag": bag.InputBag{ - "variableName": "user_name", - }, - "params": []string{ - "^[a-zA-Z0-9]+$", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field variableName must match the regex pattern ^[a-zA-Z0-9]+$.", - }, - }, - "failedNotString": map[string]any{ - "input": map[string]any{ - "selector": "variableName", - "inputBag": bag.InputBag{ - "variableName": map[string]any{ - "name": "userName", - }, - }, - "params": []string{ - "^[a-zA-Z0-9]+$", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field variableName must match the regex pattern ^[a-zA-Z0-9]+$.", - }, - }, -} - -func TestRegexRule(t *testing.T) { - rule := initRegexRule() - - for name, d := range regexRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRegexRule() *Regex { - regexRule := &Regex{} - regexRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.regex": - tr := "The field :field: must match the regex pattern :pattern:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return regexRule -} - -func TestRegex_MinRequiredParams(t *testing.T) { - rule := initRegexRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/required.go b/rules/required.go deleted file mode 100644 index 55ce196..0000000 --- a/rules/required.go +++ /dev/null @@ -1,31 +0,0 @@ -package rules - -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Required checks whether the field under validation must exist. -// This rule accepts no parameters. -// -// Usage: "required". -type Required struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation must exist. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Required) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - if !inputBag.Has(selector) { - return NewFailedResult(r.Translate(r.Locale, "validation.required", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} - -// RequiresField returns true as the Required rule requires the field to exist. -func (*Required) RequiresField() bool { - return true -} diff --git a/rules/required_if.go b/rules/required_if.go deleted file mode 100644 index 2b90100..0000000 --- a/rules/required_if.go +++ /dev/null @@ -1,57 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// RequiredIf checks whether the field under validation must exist if the given condition is true. -// The condition consists of another field name and a value. If the value of the other field is equal to the given -// value, the field under validation is required. -// Note that the only supported type for the value parameter is string. -// -// Usage: "requiredIf:otherField,value". -// Example: "requiredIf:type,user". -type RequiredIf struct { - translation.BaseTranslatableRule - otherField, expectedValue string -} - -// Validate checks if the value of the field under validation must exist if the given condition is true. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *RequiredIf) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - exists := inputBag.Has(selector) - otherValue, _ := inputBag.Get(r.otherField) - - if !exists && cast.ToString(otherValue) == r.expectedValue { - return NewFailedResult(r.Translate(r.Locale, "validation.required_if", map[string]string{ - "field": selector, - "otherField": r.otherField, - "value": r.expectedValue, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the RequiredIf rule instance. -// The first parameter specifies the `otherField` to compare against (required), -// and the second parameter, if provided, specifies the `value` to compare against (optional). -func (r *RequiredIf) AddParams(params []string) { - r.otherField = params[0] - r.expectedValue = params[1] -} - -// MinRequiredParams returns the minimum number of required parameters for the RequiredIf rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `otherField` and `value` parameters are mandatory. -func (*RequiredIf) MinRequiredParams() int { - return 2 -} - -// RequiresField returns true as the RequiredIf rule requires the field to exist. -func (*RequiredIf) RequiresField() bool { - return true -} diff --git a/rules/required_if_test.go b/rules/required_if_test.go deleted file mode 100644 index 3133212..0000000 --- a/rules/required_if_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredIfRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - "type": "user", - }, - "params": []string{ - "type", "user", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "otherFieldNotExist": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - }, - "params": []string{ - "type", "user", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "fieldNotExist": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "type": "user", - }, - "params": []string{ - "type", "user", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when type is user.", - }, - }, -} - -func TestRequiredIfRule(t *testing.T) { - rule := initRequiredIfRule() - - for name, d := range requiredIfRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredIfRule() *RequiredIf { - requiredIfRule := &RequiredIf{} - requiredIfRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required_if": - tr := "The field :field: is required when :otherField: is :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredIfRule -} - -func TestRequiredIf_MinRequiredParams(t *testing.T) { - rule := initRequiredIfRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} - -func TestRequiredIf_RequiresField(t *testing.T) { - rule := &RequiredIf{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/required_test.go b/rules/required_test.go deleted file mode 100644 index dd11287..0000000 --- a/rules/required_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "age", - "inputBag": bag.InputBag{ - "name": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field age is required.", - }, - }, -} - -func TestRequiredRule(t *testing.T) { - rule := initRequiredRule() - - for name, d := range requiredRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredRule() *Required { - requiredRule := &Required{} - requiredRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required": - tr := "The field :field: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredRule -} - -func TestRequired_RequiresField(t *testing.T) { - rule := &Required{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/required_unless.go b/rules/required_unless.go deleted file mode 100644 index 180acd5..0000000 --- a/rules/required_unless.go +++ /dev/null @@ -1,57 +0,0 @@ -package rules - -import ( - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// RequiredUnless checks whether the field under validation must exist unless the given condition is true. The -// condition consists of another field name and a value. Unless the value of the other field is equal to the given -// value, the field under validation is required. -// Note that the only supported type for the value parameter is string. -// -// Usage: "requiredUnless:otherField,value". -// Example: "requiredUnless:type,user". -type RequiredUnless struct { - translation.BaseTranslatableRule - otherField, expectedValue string -} - -// Validate checks if the value of the field under validation must exist unless the given condition is true. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *RequiredUnless) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - exists := inputBag.Has(selector) - otherValue, _ := inputBag.Get(r.otherField) - - if !exists && cast.ToString(otherValue) != r.expectedValue { - return NewFailedResult(r.Translate(r.Locale, "validation.required_unless", map[string]string{ - "field": selector, - "otherField": r.otherField, - "value": r.expectedValue, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the RequiredUnless rule instance. -// The first parameter specifies the `otherField` to compare against (required), -// and the second parameter, if provided, specifies the `value` to compare against (optional). -func (r *RequiredUnless) AddParams(params []string) { - r.otherField = params[0] - r.expectedValue = params[1] -} - -// MinRequiredParams returns the minimum number of required parameters for the RequiredUnless rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that the `otherField` and `value` parameters are mandatory. -func (*RequiredUnless) MinRequiredParams() int { - return 2 -} - -// RequiresField returns true as the RequiredUnless rule requires the field to exist. -func (*RequiredUnless) RequiresField() bool { - return true -} diff --git a/rules/required_unless_test.go b/rules/required_unless_test.go deleted file mode 100644 index abd7950..0000000 --- a/rules/required_unless_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredUnlessRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - "type": "user", - }, - "params": []string{ - "type", "machineUser", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "fieldNotExist": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "type": "user", - }, - "params": []string{ - "type", "machineUser", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required unless type is machineUser.", - }, - }, -} - -func TestRequiredUnlessRule(t *testing.T) { - rule := initRequiredUnlessRule() - - for name, d := range requiredUnlessRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredUnlessRule() *RequiredUnless { - requiredUnlessRule := &RequiredUnless{} - requiredUnlessRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required": - tr := "The field :field: is required." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - case "validation.required_unless": - tr := "The field :field: is required unless :otherField: is :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredUnlessRule -} - -func TestRequiredUnless_MinRequiredParams(t *testing.T) { - rule := initRequiredUnlessRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} - -func TestRequiredUnless_RequiresField(t *testing.T) { - rule := &RequiredUnless{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/required_with.go b/rules/required_with.go deleted file mode 100644 index 31ccfa0..0000000 --- a/rules/required_with.go +++ /dev/null @@ -1,54 +0,0 @@ -package rules - -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// RequiredWith checks whether the field under validation must exist if any of given fields exist. -// -// Usage: "requiredWith:otherField[,anotherField,...]". -// Example: "requiredWith:type". -type RequiredWith struct { - translation.BaseTranslatableRule - otherFields []string -} - -// Validate checks if the value of the field under validation must exist if any of given fields exist. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *RequiredWith) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - exists := inputBag.Has(selector) - - if exists { - return NewSuccessResult() - } - - for _, field := range r.otherFields { - if inputBag.Has(field) { - return NewFailedResult(r.Translate(r.Locale, "validation.required_with", map[string]string{ - "field": selector, - "otherField": field, - })) - } - } - - return NewSuccessResult() -} - -// AddParams sets the list of field names that this rule will check for presence, -// by assigning the given parameter values to the RequiredWith rule instance. -func (r *RequiredWith) AddParams(params []string) { - r.otherFields = params -} - -// MinRequiredParams returns the minimum number of required parameters for the RequiredWith rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that at least one other field is required. -func (*RequiredWith) MinRequiredParams() int { - return 1 -} - -// RequiresField returns true as the RequiredWith rule requires the field to exist. -func (*RequiredWith) RequiresField() bool { - return true -} diff --git a/rules/required_with_all.go b/rules/required_with_all.go deleted file mode 100644 index ea19cf2..0000000 --- a/rules/required_with_all.go +++ /dev/null @@ -1,68 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// RequiredWithAll checks whether the field under validation must exist if all given fields exist. -// -// Usage: "requiredWithAll:otherField,anotherField[,...]". -// Example: "requiredWithAll:email,username". -type RequiredWithAll struct { - translation.BaseTranslatableRule - otherFields []string -} - -// Validate checks if the value of the field under validation must exist if all given fields exist. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *RequiredWithAll) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - exists := inputBag.Has(selector) - - shouldExists := true - for _, field := range r.otherFields { - if !inputBag.Has(field) { - shouldExists = false - break - } - } - - if shouldExists && !exists { - return NewFailedResult(r.Translate(r.Locale, "validation.required_with_all", map[string]string{ - "field": selector, - "otherFields": r.getOtherFieldsConcatenated(), - })) - } - - return NewSuccessResult() -} - -// AddParams sets the list of field names that this rule will check for presence, -// by assigning the given parameter values to the RequiredWithAll rule instance. -func (r *RequiredWithAll) AddParams(params []string) { - r.otherFields = params -} - -// MinRequiredParams returns the minimum number of required parameters for the RequiredWithAll rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that at least two other fields are required. -func (*RequiredWithAll) MinRequiredParams() int { - return 2 -} - -func (r *RequiredWithAll) getOtherFieldsConcatenated() string { - if len(r.otherFields) == 2 { - return strings.Join(r.otherFields, " and ") - } - - output := strings.Join(r.otherFields, ", ") - i := strings.LastIndex(output, ",") - return output[:i+1] + " and" + output[i+1:] -} - -// RequiresField returns true as the RequiredWithAll rule requires the field to exist. -func (*RequiredWithAll) RequiresField() bool { - return true -} diff --git a/rules/required_with_all_test.go b/rules/required_with_all_test.go deleted file mode 100644 index 14e385e..0000000 --- a/rules/required_with_all_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredWithAllRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - "type": "user", - "username": "goodUser", - }, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notExistsButOK": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "username": "goodUser", - }, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "type": "user", - "username": "goodUser", - }, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when type and username are present.", - }, - }, - "failed2": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "type": "user", - "username": "goodUser", - "password": "mySecurePassword", - }, - "params": []string{ - "type", "username", "password", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when type, username, and password are present.", - }, - }, -} - -func TestRequiredWithAllRule(t *testing.T) { - rule := initRequiredWithAllRule() - - for name, d := range requiredWithAllRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredWithAllRule() *RequiredWithAll { - requiredWithAllRule := &RequiredWithAll{} - requiredWithAllRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required_with_all": - tr := "The field :field: is required when :otherFields: are present." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredWithAllRule -} - -func TestRequiredWithAll_MinRequiredParams(t *testing.T) { - rule := initRequiredWithAllRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} - -func TestRequiredWithAll_RequiresField(t *testing.T) { - rule := &RequiredWithAll{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/required_with_test.go b/rules/required_with_test.go deleted file mode 100644 index 5942229..0000000 --- a/rules/required_with_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredWithRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - "type": "user", - "username": "goodUser", - }, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotRequired": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{}, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "oneOfTheOtherFieldsExists": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "username": "goodUser", - }, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when username is present.", - }, - }, - "allOfTheOtherFieldsExists": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "type": "user", - "username": "goodUser", - }, - "params": []string{ - "type", "username", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when type is present.", - }, - }, -} - -func TestRequiredWithRule(t *testing.T) { - rule := initRequiredWithRule() - - for name, d := range requiredWithRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredWithRule() *RequiredWith { - requiredWithRule := &RequiredWith{} - requiredWithRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required_with": - tr := "The field :field: is required when :otherField: is present." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredWithRule -} - -func TestRequiredWith_MinRequiredParams(t *testing.T) { - rule := initRequiredWithRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} - -func TestRequiredWith_RequiresField(t *testing.T) { - rule := &RequiredWith{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/required_without.go b/rules/required_without.go deleted file mode 100644 index e559e76..0000000 --- a/rules/required_without.go +++ /dev/null @@ -1,54 +0,0 @@ -package rules - -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// RequiredWithout checks whether the field under validation must exist if any of given fields doesn't exist. -// -// Usage: "requiredWithout:otherField[,anotherField,...]". -// Example: "requiredWithout:username". -type RequiredWithout struct { - translation.BaseTranslatableRule - otherFields []string -} - -// Validate checks if the value of the field under validation must exist if any of given fields doesn't exist. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *RequiredWithout) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - exists := inputBag.Has(selector) - - if exists { - return NewSuccessResult() - } - - for _, field := range r.otherFields { - if !inputBag.Has(field) { - return NewFailedResult(r.Translate(r.Locale, "validation.required_without", map[string]string{ - "field": selector, - "otherField": field, - })) - } - } - - return NewSuccessResult() -} - -// AddParams sets the list of field names that this rule will check for absence, -// by assigning the given parameter values to the RequiredWithout rule instance. -func (r *RequiredWithout) AddParams(params []string) { - r.otherFields = params -} - -// MinRequiredParams returns the minimum number of required parameters for the RequiredWithout rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that at least one other field is required. -func (*RequiredWithout) MinRequiredParams() int { - return 1 -} - -// RequiresField returns true as the RequiredWithout rule requires the field to exist. -func (*RequiredWithout) RequiresField() bool { - return true -} diff --git a/rules/required_without_all.go b/rules/required_without_all.go deleted file mode 100644 index 8c15e8f..0000000 --- a/rules/required_without_all.go +++ /dev/null @@ -1,68 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// RequiredWithoutAll checks whether the field under validation must exist if all given fields not exist. -// -// Usage: "requiredWithoutAll:otherField,anotherField[,...]". -// Example: "requiredWithoutAll:phone,username". -type RequiredWithoutAll struct { - translation.BaseTranslatableRule - otherFields []string -} - -// Validate checks if the value of the field under validation must exist if all given fields not exist. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *RequiredWithoutAll) Validate(selector string, _ any, inputBag bag.InputBag) ValidationResult { - exists := inputBag.Has(selector) - - shouldExists := true - for _, field := range r.otherFields { - if inputBag.Has(field) { - shouldExists = false - break - } - } - - if shouldExists && !exists { - return NewFailedResult(r.Translate(r.Locale, "validation.required_without_all", map[string]string{ - "field": selector, - "otherFields": r.getOtherFieldsConcatenated(), - })) - } - - return NewSuccessResult() -} - -// AddParams sets the list of field names that this rule will check for absence, -// by assigning the given parameter values to the RequiredWithoutAll rule instance. -func (r *RequiredWithoutAll) AddParams(params []string) { - r.otherFields = params -} - -// MinRequiredParams returns the minimum number of required parameters for the RequiredWithoutAll rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 2, indicating that at least two other fields are required. -func (*RequiredWithoutAll) MinRequiredParams() int { - return 2 -} - -func (r *RequiredWithoutAll) getOtherFieldsConcatenated() string { - if len(r.otherFields) == 2 { - return strings.Join(r.otherFields, " and ") - } - - output := strings.Join(r.otherFields, ", ") - i := strings.LastIndex(output, ",") - return output[:i+1] + " and" + output[i+1:] -} - -// RequiresField returns true as the RequiredWithoutAll rule requires the field to exist. -func (*RequiredWithoutAll) RequiresField() bool { - return true -} diff --git a/rules/required_without_all_test.go b/rules/required_without_all_test.go deleted file mode 100644 index 68d41d5..0000000 --- a/rules/required_without_all_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredWithoutAllRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - }, - "params": []string{ - "phone", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notExistsButOK": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "username": "goodUser", - }, - "params": []string{ - "phone", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{}, - "params": []string{ - "phone", "username", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when phone and username are not present.", - }, - }, - "failed2": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{}, - "params": []string{ - "type", "username", "password", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when type, username, and password are not present.", - }, - }, -} - -func TestRequiredWithoutAllRule(t *testing.T) { - rule := initRequiredWithoutAllRule() - - for name, d := range requiredWithoutAllRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredWithoutAllRule() *RequiredWithoutAll { - requiredWithoutAllRule := &RequiredWithoutAll{} - requiredWithoutAllRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required_without_all": - tr := "The field :field: is required when :otherFields: are not present." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredWithoutAllRule -} - -func TestRequiredWithoutAll_MinRequiredParams(t *testing.T) { - rule := initRequiredWithoutAllRule() - - assert.Equal(t, 2, rule.MinRequiredParams()) -} - -func TestRequiredWithoutAll_RequiresField(t *testing.T) { - rule := &RequiredWithoutAll{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/required_without_test.go b/rules/required_without_test.go deleted file mode 100644 index a125594..0000000 --- a/rules/required_without_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var requiredWithoutRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "email": "user@example.com", - "username": "goodUser", - }, - "params": []string{ - "phone", "username", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "oneOfTheOtherFieldsNotExists": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{ - "username": "goodUser", - }, - "params": []string{ - "phone", "username", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when phone is not present.", - }, - }, - "allOfTheOtherFieldsNotExists": map[string]any{ - "input": map[string]any{ - "selector": "email", - "inputBag": bag.InputBag{}, - "params": []string{ - "phone", "username", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field email is required when phone is not present.", - }, - }, -} - -func TestRequiredWithoutRule(t *testing.T) { - rule := initRequiredWithoutRule() - - for name, d := range requiredWithoutRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initRequiredWithoutRule() *RequiredWithout { - requiredWithoutRule := &RequiredWithout{} - requiredWithoutRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.required_without": - tr := "The field :field: is required when :otherField: is not present." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return requiredWithoutRule -} - -func TestRequiredWithout_MinRequiredParams(t *testing.T) { - rule := initRequiredWithoutRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} - -func TestRequiredWithout_RequiresField(t *testing.T) { - rule := &RequiredWithout{} - assert.True(t, rule.RequiresField()) -} diff --git a/rules/same_as.go b/rules/same_as.go deleted file mode 100644 index 97a26db..0000000 --- a/rules/same_as.go +++ /dev/null @@ -1,43 +0,0 @@ -package rules - -import ( - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// SameAs checks whether the field under validation has the value same as the other given field. -// -// Usage: "sameAs:otherField". -// Example: "sameAs:password". -type SameAs struct { - translation.BaseTranslatableRule - otherField string -} - -// Validate checks if the value of the field under validation has the value same as the other given field. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *SameAs) Validate(selector string, value any, inputBag bag.InputBag) ValidationResult { - otherValue, _ := inputBag.Get(r.otherField) - - if otherValue != value { - return NewFailedResult(r.Translate(r.Locale, "validation.same_as", map[string]string{ - "field": selector, - "otherField": r.otherField, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the SameAs rule instance. -// The first parameter specifies the `otherField` to compare against (required). -func (r *SameAs) AddParams(params []string) { - r.otherField = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the SameAs rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `otherField` parameter is mandatory. -func (*SameAs) MinRequiredParams() int { - return 1 -} diff --git a/rules/same_as_test.go b/rules/same_as_test.go deleted file mode 100644 index e007663..0000000 --- a/rules/same_as_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var sameAsRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "passwordConfirmation", - "inputBag": bag.InputBag{ - "password": "mySecurePassword", - "passwordConfirmation": "mySecurePassword", - }, - "params": []string{ - "password", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notTheSame": map[string]any{ - "input": map[string]any{ - "selector": "passwordConfirmation", - "inputBag": bag.InputBag{ - "password": "mySecurePassword", - "passwordConfirmation": "anotherSecurePassword", - }, - "params": []string{ - "password", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field passwordConfirmation and password must be match.", - }, - }, - "notTheSameType": map[string]any{ - "input": map[string]any{ - "selector": "passwordConfirmation", - "inputBag": bag.InputBag{ - "password": "123456", - "passwordConfirmation": 123456, - }, - "params": []string{ - "password", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field passwordConfirmation and password must be match.", - }, - }, -} - -func TestSameAsRule(t *testing.T) { - rule := initSameAsRule() - - for name, d := range sameAsRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initSameAsRule() *SameAs { - sameAsRule := &SameAs{} - sameAsRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.same_as": - tr := "The field :field: and :otherField: must be match." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return sameAsRule -} - -func TestSameAs_MinRequiredParams(t *testing.T) { - rule := initSameAsRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/starts_with.go b/rules/starts_with.go deleted file mode 100644 index 145c857..0000000 --- a/rules/starts_with.go +++ /dev/null @@ -1,45 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// StartsWith checks whether the field under validation starts with given sub string. -// -// Usage: "startsWith:prefix". -// Example: "startsWith:Model". -type StartsWith struct { - translation.BaseTranslatableRule - prefix string -} - -// Validate checks if the value of the field under validation starts with given sub string. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *StartsWith) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if !strings.HasPrefix(cast.ToString(value), r.prefix) { - return NewFailedResult(r.Translate(r.Locale, "validation.starts_with", map[string]string{ - "field": selector, - "value": r.prefix, - })) - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the StartsWith rule instance. -// The first parameter specifies the `prefix` to compare against (required). -func (r *StartsWith) AddParams(params []string) { - r.prefix = params[0] -} - -// MinRequiredParams returns the minimum number of required parameters for the StartsWith rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 1, indicating that the `prefix` parameter is mandatory. -func (*StartsWith) MinRequiredParams() int { - return 1 -} diff --git a/rules/starts_with_test.go b/rules/starts_with_test.go deleted file mode 100644 index 2eb04a4..0000000 --- a/rules/starts_with_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var startsWithRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "className", - "inputBag": bag.InputBag{ - "className": "UserController", - }, - "params": []string{ - "User", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "className", - "inputBag": bag.InputBag{ - "className": "AccountController", - }, - "params": []string{ - "User", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field className must starts with User.", - }, - }, - "notString": map[string]any{ - "input": map[string]any{ - "selector": "className", - "inputBag": bag.InputBag{ - "className": map[string]any{ - "name": "UserController", - "path": "path/to/UserController.php", - }, - }, - "params": []string{ - "User", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field className must starts with User.", - }, - }, -} - -func TestStartsWithRule(t *testing.T) { - rule := initStartsWithRule() - - for name, d := range startsWithRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - rule.AddParams(input["params"].([]string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initStartsWithRule() *StartsWith { - startsWithRule := &StartsWith{} - startsWithRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.starts_with": - tr := "The field :field: must starts with :value:." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return startsWithRule -} - -func TestStartsWith_MinRequiredParams(t *testing.T) { - rule := initStartsWithRule() - - assert.Equal(t, 1, rule.MinRequiredParams()) -} diff --git a/rules/string.go b/rules/string.go deleted file mode 100644 index 19cbdcf..0000000 --- a/rules/string.go +++ /dev/null @@ -1,34 +0,0 @@ -package rules - -import ( - "reflect" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// String checks whether the field under validation has a string value -// This rule accepts no parameters. -// -// Usage: "string". -type String struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation has a string value. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *String) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - if value == nil { - return NewSuccessResult() - } - v := indirectValue(value) - - switch v.Kind() { - case reflect.String: - return NewSuccessResult() - default: - return NewFailedResult(r.Translate(r.Locale, "validation.string", map[string]string{ - "field": selector, - })) - } -} diff --git a/rules/string_test.go b/rules/string_test.go deleted file mode 100644 index fa4deb2..0000000 --- a/rules/string_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var stringRuleTestData = map[string]any{ - "successfulString": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulEmptyString": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNotExists": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedFloat": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": 25.9, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must have an string value.", - }, - }, - "failedInteger": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": 1989, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must have an string value.", - }, - }, - "failedSlice": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": []string{"John", "Doe"}, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must have an string value.", - }, - }, - "failedMap": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": map[string]any{ - "username": "johnDoe", - "name": "John Doe", - }, - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must have an string value.", - }, - }, -} - -func TestStringRule(t *testing.T) { - rule := initStringRule() - - for name, d := range stringRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initStringRule() *String { - stringRule := &String{} - stringRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.string": - tr := "The field :field: must have an string value." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return stringRule -} diff --git a/rules/timezone.go b/rules/timezone.go deleted file mode 100644 index fd0eed9..0000000 --- a/rules/timezone.go +++ /dev/null @@ -1,35 +0,0 @@ -package rules - -import ( - "time" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Timezone checks whether the field under validation is a valid IANA time zone name. -// This rule accepts no parameters. -// -// Usage: "timezone". -type Timezone struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid IANA time zone name. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Timezone) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - name := cast.ToString(value) - if name == "" { - return NewFailedResult(r.Translate(r.Locale, "validation.timezone", map[string]string{ - "field": selector, - })) - } - if _, err := time.LoadLocation(name); err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.timezone", map[string]string{ - "field": selector, - })) - } - return NewSuccessResult() -} diff --git a/rules/timezone_test.go b/rules/timezone_test.go deleted file mode 100644 index f0cd616..0000000 --- a/rules/timezone_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -func TestTimezoneRule(t *testing.T) { - rule := initTimezoneRule() - - tests := map[string]any{ - "ok": map[string]any{ - "input": map[string]any{"selector": "tz", "inputBag": bag.InputBag{"tz": "America/New_York"}}, - "output": map[string]any{"validationFailed": false, "validationError": ""}, - }, - "invalid": map[string]any{ - "input": map[string]any{"selector": "tz", "inputBag": bag.InputBag{"tz": "Not/AZone"}}, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field tz must be a valid timezone.", - }, - }, - "empty": map[string]any{ - "input": map[string]any{"selector": "tz", "inputBag": bag.InputBag{"tz": ""}}, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field tz must be a valid timezone.", - }, - }, - } - - for name, d := range tests { - t.Run(name, func(t *testing.T) { - data := d.(map[string]any) - input := data["input"].(map[string]any) - output := data["output"].(map[string]any) - inputBag := input["inputBag"].(bag.InputBag) - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initTimezoneRule() *Timezone { - r := &Timezone{} - r.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - switch key { - case "validation.timezone": - tr := "The field :field: must be a valid timezone." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - return tr - default: - return key - } - }) - return r -} diff --git a/rules/uppercase.go b/rules/uppercase.go deleted file mode 100644 index b2fe118..0000000 --- a/rules/uppercase.go +++ /dev/null @@ -1,32 +0,0 @@ -package rules - -import ( - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// Uppercase checks whether the field under validation be uppercase string. -// This rule accepts no parameters. -// -// Usage: "uppercase". -type Uppercase struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation be uppercase string. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *Uppercase) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strValue := cast.ToString(value) - - if strValue != strings.ToUpper(strValue) { - return NewFailedResult(r.Translate(r.Locale, "validation.uppercase", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/uppercase_test.go b/rules/uppercase_test.go deleted file mode 100644 index 8318aa2..0000000 --- a/rules/uppercase_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var uppercaseRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "JOHN", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "notUppercase": map[string]any{ - "input": map[string]any{ - "selector": "name", - "inputBag": bag.InputBag{ - "name": "John Doe", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field name must be uppercase.", - }, - }, -} - -func TestUppercaseRule(t *testing.T) { - rule := initUppercaseRule() - - for name, d := range uppercaseRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initUppercaseRule() *Uppercase { - uppercaseRule := &Uppercase{} - uppercaseRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.uppercase": - tr := "The field :field: must be uppercase." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return uppercaseRule -} diff --git a/rules/url.go b/rules/url.go deleted file mode 100644 index c8638e2..0000000 --- a/rules/url.go +++ /dev/null @@ -1,123 +0,0 @@ -package rules - -import ( - "net" - "net/url" - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -// URL checks whether the field under validation is a valid URL with scheme and host. -// -// Usage: "url[:scheme]". -// Example: "url". -// Example: "url:scheme". -type URL struct { - translation.BaseTranslatableRule - requireScheme bool -} - -// Validate checks if the value of the field under validation is a valid URL with scheme and host. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *URL) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - raw := cast.ToString(value) - if raw == "" { - return NewSuccessResult() - } - // First, try to parse the URL as-is. - u, err := url.ParseRequestURI(raw) - if r.requireScheme { - if err != nil || u.Scheme == "" || !isValidURLHost(u.Host) { - return NewFailedResult( - r.Translate( - r.Locale, "validation.url", map[string]string{ - "field": selector, - }, - ), - ) - } - } else { - // Accept URLs without scheme by attempting to parse with an implied scheme - if err != nil || !isValidURLHost(u.Host) { - u2, err2 := url.Parse("http://" + raw) - if err2 != nil || !isValidURLHost(u2.Host) { - return NewFailedResult( - r.Translate( - r.Locale, "validation.url", map[string]string{ - "field": selector, - }, - ), - ) - } - } - } - - return NewSuccessResult() -} - -// AddParams assigns the provided parameter values to the URL rule instance. -// The first parameter specifies the `scheme` to compare against (optional). -// The second parameter, if provided, specifies the `scheme` to compare against (optional). -func (r *URL) AddParams(params []string) { - for _, p := range params { - if p == "scheme" { - r.requireScheme = true - } - } -} - -// MinRequiredParams returns the minimum number of required parameters for the URL rule. -// It specifies how many parameters must be provided when configuring this rule. -// Returns 0, indicating that no parameters are required. -func (*URL) MinRequiredParams() int { return 0 } - -// isValidURLHost performs additional checks to make sure the host part of the URL -// looks like a real network host and not just arbitrary text. -// -// It accepts: -// - domain-like hosts with at least one dot (e.g. "example.com") -// - "localhost" -// - valid IP addresses (IPv4 / IPv6, with or without port). -func isValidURLHost(host string) bool { - if host == "" { - return false - } - - // Strip port if present (e.g. "example.com:8080" or "[::1]:8080") - if strings.HasPrefix(host, "[") { - // For IPv6 in brackets, keep the address inside brackets, drop the port. - if idx := strings.LastIndex(host, "]"); idx != -1 { - host = host[1:idx] - } - } else if h, _, ok := strings.Cut(host, ":"); ok { - host = h - } - - // Accept valid IPs. - if net.ParseIP(host) != nil { - return true - } - - // Accept localhost explicitly. - if host == "localhost" { - return true - } - - // For domain-like hosts, require at least one dot and no empty labels. - if !strings.Contains(host, ".") { - return false - } - - parts := strings.Split(host, ".") - for _, p := range parts { - if p == "" { - return false - } - } - - return true -} diff --git a/rules/url_test.go b/rules/url_test.go deleted file mode 100644 index 26f728b..0000000 --- a/rules/url_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var urlRuleTestData = map[string]any{ - "successfulHttp": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "https://example.com/path?q=1", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "successfulNoScheme": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "example.com/path?q=1", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failedNoScheme": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "example.com", - }, - "params": []string{"scheme"}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field site must be a valid url.", - }, - }, - "failedImpliedScheme": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "/relative/path", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field site must be a valid url.", - }, - }, - "failedGibberishNoScheme": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "jhfdskhk", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field site must be a valid url.", - }, - }, - "failedGibberishWithScheme": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "http://jhfdskhk", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field site must be a valid url.", - }, - }, - "emptyInput": map[string]any{ - "input": map[string]any{ - "selector": "site", - "inputBag": bag.InputBag{ - "site": "", - }, - "params": []string{}, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, -} - -func TestURLRule(t *testing.T) { - for name, d := range urlRuleTestData { - t.Run( - name, func(t *testing.T) { - rule := initURLRule() - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - - if params, ok := input["params"].([]string); ok { - rule.AddParams(params) - } - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }, - ) - } -} - -func initURLRule() *URL { - urlRule := &URL{} - urlRule.AddTranslationFunction( - func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.url": - tr := "The field :field: must be a valid url." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }, - ) - return urlRule -} diff --git a/rules/uuid.go b/rules/uuid.go deleted file mode 100644 index c10cbd2..0000000 --- a/rules/uuid.go +++ /dev/null @@ -1,35 +0,0 @@ -package rules - -import ( - "regexp" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/translation" -) - -const uuidRegex = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$` - -// UUID checks whether the field under validation is a valid uuid. -// This rule accepts no parameters. -// -// Usage: "uuid". -type UUID struct { - translation.BaseTranslatableRule -} - -// Validate checks if the value of the field under validation is a valid uuid. -// It returns a ValidationResult that indicates success if valid, or the appropriate error message if the check fails. -func (r *UUID) Validate(selector string, value any, _ bag.InputBag) ValidationResult { - strVal := cast.ToString(value) - - ok, err := regexp.MatchString(uuidRegex, strVal) - if !ok || err != nil { - return NewFailedResult(r.Translate(r.Locale, "validation.uuid", map[string]string{ - "field": selector, - })) - } - - return NewSuccessResult() -} diff --git a/rules/uuid_test.go b/rules/uuid_test.go deleted file mode 100644 index 654e8b5..0000000 --- a/rules/uuid_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package rules - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/behzadsh/go.validator/bag" -) - -var uuidRuleTestData = map[string]any{ - "successful": map[string]any{ - "input": map[string]any{ - "selector": "id", - "inputBag": bag.InputBag{ - "id": "c27b23b2-932a-469c-b98f-9150a092d002", - }, - }, - "output": map[string]any{ - "validationFailed": false, - "validationError": "", - }, - }, - "failed": map[string]any{ - "input": map[string]any{ - "selector": "id", - "inputBag": bag.InputBag{ - "id": "g27b2yb2-93va-469c-b98f-9150a092h002", - }, - }, - "output": map[string]any{ - "validationFailed": true, - "validationError": "The field id is not a valid uuid.", - }, - }, -} - -func TestUUIDRule(t *testing.T) { - rule := initUUIDRule() - - for name, d := range uuidRuleTestData { - t.Run(name, func(t *testing.T) { - data, _ := d.(map[string]any) - input, _ := data["input"].(map[string]any) - output, _ := data["output"].(map[string]any) - inputBag, _ := input["inputBag"].(bag.InputBag) - - value, _ := inputBag.Get(input["selector"].(string)) - res := rule.Validate(input["selector"].(string), value, inputBag) - - assert.Equal(t, output["validationFailed"].(bool), res.Failed()) - assert.Equal(t, output["validationError"].(string), res.Message()) - }) - } -} - -func initUUIDRule() *UUID { - uuidRule := &UUID{} - uuidRule.AddTranslationFunction(func(_, key string, params ...map[string]string) string { - var p map[string]string - if len(params) > 0 { - p = params[0] - } - - switch key { - case "validation.uuid": - tr := "The field :field: is not a valid uuid." - for k, v := range p { - tr = strings.ReplaceAll(tr, ":"+k+":", v) - } - - return tr - default: - return key - } - }) - return uuidRule -} diff --git a/string_rules.go b/string_rules.go new file mode 100644 index 0000000..e2fa72a --- /dev/null +++ b/string_rules.go @@ -0,0 +1,758 @@ +package validation + +import ( + "encoding/base64" + "encoding/json" + "net" + "regexp" + "strings" + "unicode/utf8" +) + +const ( + emailUsernameRegexPattern = "^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+$" + emailDomainRegexPattern = "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\\.[a-zA-Z]{2,}$" + semverRegexPattern = `^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$` +) + +var ( + regexAlpha = regexp.MustCompile(`^[\pL\pM]+$`) + regexAlphaDash = regexp.MustCompile(`^[\pL\pM\pN_-]+$`) + regexAlphaNum = regexp.MustCompile(`^[\pL\pM\pN]+$`) + regexAlphaSpace = regexp.MustCompile(`^[\pL\pM\s]+$`) + regexEmailUsername = regexp.MustCompile(emailUsernameRegexPattern) + regexEmailDomain = regexp.MustCompile(emailDomainRegexPattern) + regexHexColor = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$`) + regexJWT = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`) + regexPhoneE164 = regexp.MustCompile(`^\+[1-9]\d{1,14}$`) + regexSemver = regexp.MustCompile(semverRegexPattern) + regexSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + regexUUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +) + +// Alpha is a Rule that validates that the value is a string containing only Unicode letters. +// +// Fails if: +// - value is not a string +// - value contains digits, spaces, punctuation, or any non-letter character +// - value is an empty string +// +// Examples: +// +// validation.Alpha.Validate("hello") // pass +// validation.Alpha.Validate("Ünïcödé") // pass — Unicode letters accepted +// validation.Alpha.Validate("hello1") // fail — contains digit +// validation.Alpha.Validate("hi there") // fail — contains space +var Alpha Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexAlpha.MatchString(str) { + return basicError{"alpha", "alpha validation failed"} + } + + return nil + }, +) + +// AlphaDash is a Rule that validates that the value is a string containing only Unicode letters, digits, underscores, +// and dashes. +// +// Fails if: +// - value is not a string +// - value contains spaces, punctuation other than _ and -, or any other non-alphanumeric character +// - value is an empty string +// +// Examples: +// +// validation.AlphaDash.Validate("hello-world") // pass +// validation.AlphaDash.Validate("hello_world") // pass +// validation.AlphaDash.Validate("hello123") // pass +// validation.AlphaDash.Validate("hello world") // fail — space not allowed +// validation.AlphaDash.Validate("hello@world") // fail — @ not allowed +var AlphaDash Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexAlphaDash.MatchString(str) { + return basicError{"alpha_dash", "alpha dash validation failed"} + } + + return nil + }, +) + +// AlphaNum is a Rule that validates that the value is a string containing only Unicode letters and digits. +// +// Fails if: +// - value is not a string +// - value contains spaces, dashes, underscores, or any non-alphanumeric character +// - value is an empty string +// +// Examples: +// +// validation.AlphaNum.Validate("hello123") // pass +// validation.AlphaNum.Validate("ABC") // pass +// validation.AlphaNum.Validate("hello-1") // fail — dash not allowed +// validation.AlphaNum.Validate("hello 1") // fail — space not allowed +var AlphaNum Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexAlphaNum.MatchString(str) { + return basicError{"alpha_num", "alpha num validation failed"} + } + + return nil + }, +) + +// AlphaSpace is a Rule that validates that the value is a string containing only Unicode letters and whitespace. +// +// Fails if: +// - value is not a string +// - value contains digits, punctuation, or any non-letter non-whitespace character +// - value is an empty string +// +// Examples: +// +// validation.AlphaSpace.Validate("hello world") // pass +// validation.AlphaSpace.Validate("Ünïcödé") // pass +// validation.AlphaSpace.Validate("hello1") // fail — digit not allowed +// validation.AlphaSpace.Validate("hello-world") // fail — dash not allowed +var AlphaSpace Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexAlphaSpace.MatchString(str) { + return basicError{"alpha_space", "alpha space validation failed"} + } + + return nil + }, +) + +// ASCII is a Rule that validates the value is a string containing only ASCII characters (bytes 0–127). +// +// Fails if: +// - value is not a string +// - the string contains any byte with value > 127 +// +// Examples: +// +// validation.ASCII.Validate("hello") // pass +// validation.ASCII.Validate("café") // fail — é is not ASCII +// validation.ASCII.Validate("hello\t") // pass — tab is ASCII +var ASCII Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"ascii", "ascii validation failed"} + } + + for i := 0; i < len(str); i++ { + if str[i] > 127 { + return basicError{"ascii", "ascii validation failed"} + } + } + + return nil + }, +) + +// Base64 returns a Rule that validates the value is a valid standard base64-encoded string (RFC 4648, +// with padding). URL-safe base64 (using - and _) is not accepted. +// +// An empty string passes (it encodes zero bytes). +// +// Fails if: +// - value is not a string +// - the string is not valid standard base64 +// +// Examples: +// +// validation.Base64.Validate("aGVsbG8=") // pass — "hello" +// validation.Base64.Validate("aGVsbG8") // fail — missing padding +// validation.Base64.Validate("not-base64!") // fail +var Base64 Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"base64", "base64 validation failed"} + } + + if _, err := base64.StdEncoding.DecodeString(str); err != nil { + return basicError{"base64", "base64 validation failed"} + } + + return nil + }, +) + +// Contains returns a Rule that validates the value is a string containing the given substring. +// +// Fails if: +// - value is not a string +// - the string does not contain sub +// +// Examples: +// +// validation.Contains("world").Validate("hello world") // pass +// validation.Contains("world").Validate("hello") // fail +func Contains(sub string) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !strings.Contains(str, sub) { + return containsError{Substring: sub} + } + + return nil + }, + ) +} + +// CreditCard is a Rule that validates the value is a valid credit card number using the Luhn algorithm. +// +// Spaces and dashes are stripped before validation. Accepted length after stripping: 13–19 digits. +// +// Fails if: +// - value is not a string +// - the string (after stripping spaces/dashes) is not 13–19 digits +// - the Luhn checksum does not pass +// +// Examples: +// +// validation.CreditCard.Validate("4111111111111111") // pass — Visa test number +// validation.CreditCard.Validate("4111-1111-1111-1111") // pass — dashes stripped +// validation.CreditCard.Validate("1234567890123456") // fail — invalid Luhn +var CreditCard Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok { + return basicError{"credit_card", "credit card validation failed"} + } + + cleaned := strings.NewReplacer(" ", "", "-", "").Replace(str) + if len(cleaned) < 13 || len(cleaned) > 19 { + return basicError{"credit_card", "credit card validation failed"} + } + + for _, c := range cleaned { + if c < '0' || c > '9' { + return basicError{"credit_card", "credit card validation failed"} + } + } + + if !luhn(cleaned) { + return basicError{"credit_card", "credit card validation failed"} + } + + return nil + }, +) + +// Email is a Rule that validates the value is a well-formed email address. +// +// Fails if: +// - value is not a string +// - value has no "@" or more than one "@" +// - the username portion contains characters outside the allowed RFC set +// - the domain portion has no dot, empty labels, or a TLD shorter than two characters +// +// Examples: +// +// validation.Email.Validate("user@example.com") // pass +// validation.Email.Validate("user+tag@sub.example.org") // pass +// validation.Email.Validate("notanemail") // fail — no @ +// validation.Email.Validate("user@") // fail — empty domain +// validation.Email.Validate("@example.com") // fail — empty username +var Email Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !isEmail(str) { + return basicError{"email", "email validation failed"} + } + + return nil + }, +) + +// EmailMX is a Rule that validates the value is a well-formed email address whose domain has at least one MX record. +// +// Fails if: +// - value is not a string or is not a valid email format (returns basicError{"email", "email validation failed"}) +// - the domain has no MX records (returns basicError{"email_mx", "email mx validation failed"}) +// +// Note: this rule performs a network call on every invocation. Avoid in hot paths; cache results externally if needed. +// +// Examples: +// +// validation.EmailMX.Validate("user@gmail.com") // pass — gmail.com has MX records +// validation.EmailMX.Validate("user@invalid.com") // fail — example.com has no MX records +// validation.EmailMX.Validate("notanemail") // fail — format invalid +var EmailMX Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !isEmail(str) { + return basicError{"email", "email validation failed"} + } + + domain := strings.SplitN(str, "@", 2)[1] + if _, err := net.LookupMX(domain); err != nil { + return basicError{"email_mx", "email mx validation failed"} + } + + return nil + }, +) + +// EndsWith returns a Rule that validates the value is a string ending with the given suffix. +// +// Fails if: +// - value is not a string +// - the string does not end with suffix +// +// Examples: +// +// validation.EndsWith(".go").Validate("main.go") // pass +// validation.EndsWith(".go").Validate("main.js") // fail +func EndsWith(suffix string) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !strings.HasSuffix(str, suffix) { + return endsWithError{Suffix: suffix} + } + + return nil + }, + ) +} + +// HexColor is a Rule that validates the value is a valid CSS hex color string. +// +// Accepted formats: #RGB and #RRGGBB (case-insensitive). +// +// Fails if: +// - value is not a string +// - the string is not a valid 3- or 6-digit hex color +// +// Examples: +// +// validation.HexColor.Validate("#fff") // pass — short form +// validation.HexColor.Validate("#FF5733") // pass — long form +// validation.HexColor.Validate("FF5733") // fail — missing # +// validation.HexColor.Validate("#GGHHII") // fail — not hex digits +var HexColor Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexHexColor.MatchString(str) { + return basicError{"hex_color", "hex color validation failed"} + } + + return nil + }, +) + +// JSON is a Rule that validates the value is a string containing valid JSON. +// +// Any valid JSON value is accepted: object, array, string, number, boolean, or null. +// An empty string fails. +// +// Fails if: +// - value is not a string +// - the string is not valid JSON +// +// Examples: +// +// validation.JSON.Validate(`{"key":"value"}`) // pass +// validation.JSON.Validate(`[1,2,3]`) // pass +// validation.JSON.Validate(`null`) // pass +// validation.JSON.Validate(`"hello"`) // pass +// validation.JSON.Validate(`{invalid}`) // fail +// validation.JSON.Validate(``) // fail +var JSON Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !json.Valid([]byte(str)) { + return basicError{"json", "json validation failed"} + } + + return nil + }, +) + +// JWT is a Rule that validates the value is a string with a valid JWT format. +// +// A JWT must consist of exactly three dot-separated base64url-encoded segments (header.payload.signature). +// Padding characters are not accepted (standard JWT compact serialization). The content of each segment +// is not decoded or validated. +// +// Fails if: +// - value is not a string +// - the string does not match the three-segment JWT format +// +// Examples: +// +// validation.JWT.Validate("eyJ.eyJ.sig") // pass +// validation.JWT.Validate("notajwt") // fail — only one segment +// validation.JWT.Validate("a.b") // fail — only two segments +var JWT Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexJWT.MatchString(str) { + return basicError{"jwt", "jwt validation failed"} + } + + return nil + }, +) + +// Length returns a Rule that validates the string's rune count is exactly equal to l. +// +// Rune count is used, not byte length, so multibyte characters such as "é" count as one. +// +// Fails if: +// - value is not a string +// - the string has fewer or more runes than l +// +// Examples: +// +// validation.Length(5).Validate("hello") // pass — 5 runes +// validation.Length(5).Validate("héllo") // pass — 5 runes (é is one rune) +// validation.Length(5).Validate("hi") // fail — 2 runes +// validation.Length(5).Validate("too long") // fail — 8 runes +func Length(l int) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || utf8.RuneCountInString(str) != l { + return lengthError{Length: l} + } + + return nil + }, + ) +} + +// Lowercase is a Rule that validates the value is a string containing only lowercase characters. +// +// Fails if: +// - value is not a string +// - the string contains any uppercase character +// +// Examples: +// +// validation.Lowercase.Validate("hello") // pass +// validation.Lowercase.Validate("hello world") // pass +// validation.Lowercase.Validate("Hello") // fail +// validation.Lowercase.Validate("HELLO") // fail +var Lowercase Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || str != strings.ToLower(str) { + return basicError{"lowercase", "lowercase validation failed"} + } + + return nil + }, +) + +// MaxLength returns a Rule that validates the string's rune count is at most l. +// +// Rune count is used, not byte length, so multibyte characters such as "é" count as one. +// +// Fails if: +// - value is not a string +// - the string has more than l runes +// +// Examples: +// +// validation.MaxLength(10).Validate("hello") // pass — 5 runes <= 10 +// validation.MaxLength(3).Validate("too long") // fail — 8 runes > 3 +// validation.MaxLength(3).Validate("héé") // pass — 3 runes +func MaxLength(l int) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || utf8.RuneCountInString(str) > l { + return maxLengthError{Length: l} + } + + return nil + }, + ) +} + +// MinLength returns a Rule that validates the string's rune count is at least l. +// +// Rune count is used, not byte length, so multibyte characters such as "é" count as one. +// +// Fails if: +// - value is not a string +// - the string has fewer than l runes +// +// Examples: +// +// validation.MinLength(3).Validate("hello") // pass — 5 runes >= 3 +// validation.MinLength(3).Validate("hi") // fail — 2 runes < 3 +// validation.MinLength(3).Validate("héé") // pass — 3 runes +func MinLength(l int) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || utf8.RuneCountInString(str) < l { + return minLengthError{Length: l} + } + + return nil + }, + ) +} + +// NotRegex returns a Rule that validates the value is a string that does NOT match the given regular expression. +// +// The pattern is compiled once at call time. If the pattern is invalid, Schema.Validate returns a +// RuleSyntaxError — treat that as a programming error and fix the schema at startup. +// +// Fails if: +// - value is not a string +// - the string matches the pattern +// +// Examples: +// +// validation.NotRegex(`\s`).Validate("nospaces") // pass — no whitespace +// validation.NotRegex(`\s`).Validate("has spaces") // fail — contains whitespace +func NotRegex(pattern string) Rule { + re, err := regexp.Compile(pattern) + + return RuleFunc( + func(value any) error { + if err != nil { + return RuleSyntaxError{Rule: "NotRegex", Err: err} + } + + str, ok := value.(string) + if !ok || re.MatchString(str) { + return notRegexError{Pattern: pattern} + } + + return nil + }, + ) +} + +// PhoneE164 is a Rule that validates the value is a phone number in E.164 format. +// +// E.164 format: a leading +, followed by a country code digit (1–9), followed by 1–14 more digits. +// Total digits (excluding +): 2–15. +// +// Fails if: +// - value is not a string +// - the string does not match E.164 format +// +// Examples: +// +// validation.PhoneE164.Validate("+14155552671") // pass — US number +// validation.PhoneE164.Validate("+441234567890") // pass — UK number +// validation.PhoneE164.Validate("14155552671") // fail — missing + +// validation.PhoneE164.Validate("+0123456789") // fail — country code starts with 0 +var PhoneE164 Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexPhoneE164.MatchString(str) { + return basicError{"phone_e164", "phone e164 validation failed"} + } + + return nil + }, +) + +// Regex returns a Rule that validates the value is a string matching the given regular expression. +// +// The pattern is compiled once at call time. If the pattern is invalid, Schema.Validate returns a +// RuleSyntaxError — treat that as a programming error and fix the schema at startup. +// +// Fails if: +// - value is not a string +// - the string does not match the pattern +// +// Examples: +// +// validation.Regex(`^\d{4}$`).Validate("1234") // pass +// validation.Regex(`^\d{4}$`).Validate("12345") // fail — too many digits +// validation.Regex(`^\d{4}$`).Validate("abcd") // fail — not digits +func Regex(pattern string) Rule { + re, err := regexp.Compile(pattern) + + return RuleFunc( + func(value any) error { + if err != nil { + return RuleSyntaxError{Rule: "Regex", Err: err} + } + + str, ok := value.(string) + if !ok || !re.MatchString(str) { + return regexError{Pattern: pattern} + } + + return nil + }, + ) +} + +// Semver is a Rule that validates the value is a valid semantic version string (semver.org). +// +// Both prefixed ("v1.2.3") and un-prefixed ("1.2.3") forms are accepted. Supports pre-release +// identifiers (-alpha.1) and build metadata (+001). To enforce the "v" prefix, compose with +// StartsWith("v"). +// +// Fails if: +// - value is not a string +// - the string is not a valid semver (e.g. leading zeros in numeric identifiers, missing components) +// +// Examples: +// +// validation.Semver.Validate("1.0.0") // pass +// validation.Semver.Validate("v1.0.0") // pass — v prefix accepted +// validation.Semver.Validate("2.1.3-alpha.1") // pass +// validation.Semver.Validate("1.0.0+build.123") // pass +// validation.Semver.Validate("1.0") // fail — missing patch +// validation.Semver.Validate("01.0.0") // fail — leading zero +var Semver Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexSemver.MatchString(str) { + return basicError{"semver", "semver validation failed"} + } + + return nil + }, +) + +// Slug is a Rule that validates the value is a URL-friendly slug. +// +// A slug consists of lowercase ASCII letters, digits, and hyphens. It must not start or end with +// a hyphen and must not contain consecutive hyphens. +// +// Fails if: +// - value is not a string +// - the string contains uppercase letters, non-ASCII characters, spaces, or invalid hyphens +// - the string is empty +// +// Examples: +// +// validation.Slug.Validate("hello-world") // pass +// validation.Slug.Validate("my-post-123") // pass +// validation.Slug.Validate("Hello-World") // fail — uppercase +// validation.Slug.Validate("-leading") // fail — leading hyphen +// validation.Slug.Validate("double--dash") // fail — consecutive hyphens +var Slug Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexSlug.MatchString(str) { + return basicError{"slug", "slug validation failed"} + } + + return nil + }, +) + +// StartsWith returns a Rule that validates the value is a string beginning with the given prefix. +// +// Fails if: +// - value is not a string +// - the string does not start with prefix +// +// Examples: +// +// validation.StartsWith("SKU-").Validate("SKU-001") // pass +// validation.StartsWith("SKU-").Validate("001-SKU") // fail +func StartsWith(prefix string) Rule { + return RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !strings.HasPrefix(str, prefix) { + return startsWithError{Prefix: prefix} + } + + return nil + }, + ) +} + +// Uppercase is a Rule that validates the value is a string containing only uppercase characters. +// +// Fails if: +// - value is not a string +// - the string contains any lowercase character +// +// Examples: +// +// validation.Uppercase.Validate("HELLO") // pass +// validation.Uppercase.Validate("HELLO WORLD") // pass +// validation.Uppercase.Validate("Hello") // fail +// validation.Uppercase.Validate("hello") // fail +var Uppercase Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || str != strings.ToUpper(str) { + return basicError{"uppercase", "uppercase validation failed"} + } + + return nil + }, +) + +// UUID is a Rule that validates the value is a valid UUID string (any variant, case-insensitive). +// +// Fails if: +// - value is not a string +// - the string does not match the UUID format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +// +// Examples: +// +// validation.UUID.Validate("550e8400-e29b-41d4-a716-446655440000") // pass +// validation.UUID.Validate("not-a-uuid") // fail +var UUID Rule = RuleFunc( + func(value any) error { + str, ok := value.(string) + if !ok || !regexUUID.MatchString(str) { + return basicError{"uuid", "uuid validation failed"} + } + + return nil + }, +) + +func luhn(number string) bool { + sum := 0 + nDigits := len(number) + parity := nDigits % 2 + for i := 0; i < nDigits; i++ { + digit := int(number[i] - '0') + if i%2 == parity { + digit *= 2 + if digit > 9 { + digit -= 9 + } + } + sum += digit + } + + return sum%10 == 0 +} + +func isEmail(str string) bool { + parts := strings.Split(str, "@") + if len(parts) != 2 { + return false + } + + if !regexEmailUsername.MatchString(parts[0]) { + return false + } + + if !regexEmailDomain.MatchString(parts[1]) { + return false + } + + return true +} diff --git a/string_rules_test.go b/string_rules_test.go new file mode 100644 index 0000000..23090ad --- /dev/null +++ b/string_rules_test.go @@ -0,0 +1,668 @@ +package validation + +import ( + "errors" + "testing" +) + +func TestAlpha(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"abc", false}, + {"Ünïcödé", false}, + {"abc123", true}, + {"abc-def", true}, + {"", true}, + {123, true}, + {nil, true}, + } + for _, tt := range tests { + err := Alpha.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Alpha.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "alpha" { + t.Errorf("Alpha.Validate(%v) wrong error type: %v", tt.value, err) + } + } +} + +func TestAlphaDash(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"abc", false}, + {"abc-def", false}, + {"abc_def", false}, + {"abc123", false}, + {"abc def", true}, + {"abc@def", true}, + {"", true}, + {42, true}, + } + for _, tt := range tests { + err := AlphaDash.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("AlphaDash.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } +} + +func TestAlphaNum(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"abc123", false}, + {"ABC", false}, + {"abc-123", true}, + {"abc 123", true}, + {"", true}, + {99, true}, + } + for _, tt := range tests { + err := AlphaNum.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("AlphaNum.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } +} + +func TestAlphaSpace(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello world", false}, + {"Hello World", false}, + {"Ünïcödé chars", false}, + {"hello123", true}, + {"hello-world", true}, + {"", true}, + {nil, true}, + } + for _, tt := range tests { + err := AlphaSpace.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("AlphaSpace.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } +} + +func TestLength(t *testing.T) { + tests := []struct { + value any + l int + wantErr bool + }{ + {"hello", 5, false}, + {"héllo", 5, false}, // 5 runes, >5 bytes + {"héllo", 6, true}, // 5 runes != 6 + {"hi", 5, true}, + {"", 0, false}, + {"", 1, true}, + {42, 5, true}, + {nil, 0, true}, + } + for _, tt := range tests { + err := Length(tt.l).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Length(%d).Validate(%v) error = %v, wantErr %v", tt.l, tt.value, err, tt.wantErr) + } + } +} + +func TestMinLength(t *testing.T) { + tests := []struct { + value any + l int + wantErr bool + }{ + {"hello", 3, false}, + {"hello", 5, false}, + {"héllo", 5, false}, // 5 runes + {"héllo", 6, true}, // 5 runes < 6 + {"hi", 5, true}, + {"", 1, true}, + {"", 0, false}, + {42, 3, true}, + } + for _, tt := range tests { + err := MinLength(tt.l).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MinLength(%d).Validate(%v) error = %v, wantErr %v", tt.l, tt.value, err, tt.wantErr) + } + } +} + +func TestMaxLength(t *testing.T) { + tests := []struct { + value any + l int + wantErr bool + }{ + {"hi", 5, false}, + {"hello", 5, false}, + {"héllo", 5, false}, // 5 runes <= 5 + {"hello!", 5, true}, + {"héllo!", 5, true}, // 6 runes > 5 + {"", 0, false}, + {"a", 0, true}, + {42, 5, true}, + } + for _, tt := range tests { + err := MaxLength(tt.l).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("MaxLength(%d).Validate(%v) error = %v, wantErr %v", tt.l, tt.value, err, tt.wantErr) + } + } +} + +func TestEmail(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"user@example.com", false}, + {"user+tag@sub.example.org", false}, + {"user.name@example.co.uk", false}, + {"notanemail", true}, + {"@example.com", true}, + {"user@", true}, + {"user@@example.com", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Email.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Email.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } +} + +func TestEmailMX(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + tests := []struct { + value any + wantErr bool + }{ + {"user@gmail.com", false}, + {"user@invalid.com", true}, // example.com has no MX records + {"notanemail", true}, + {nil, true}, + } + for _, tt := range tests { + err := EmailMX.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("EmailMX.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + } +} + +func TestUUID(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"550e8400-e29b-41d4-a716-446655440000", false}, + {"550E8400-E29B-41D4-A716-446655440000", false}, // uppercase + {"00000000-0000-0000-0000-000000000000", false}, + {"not-a-uuid", true}, + {"550e8400-e29b-41d4-a716", true}, // too short + {"550e8400e29b41d4a716446655440000", true}, // no dashes + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := UUID.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("UUID.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "uuid" { + t.Errorf("UUID.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestRegex(t *testing.T) { + tests := []struct { + value any + pattern string + wantErr bool + }{ + {"1234", `^\d{4}$`, false}, + {"12345", `^\d{4}$`, true}, + {"abcd", `^\d{4}$`, true}, + {"hello", `^[a-z]+$`, false}, + {"Hello", `^[a-z]+$`, true}, + {"", `^\d{4}$`, true}, + {nil, `^\d{4}$`, true}, + {42, `^\d{4}$`, true}, + } + for _, tt := range tests { + err := Regex(tt.pattern).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Regex(%q).Validate(%v) error = %v, wantErr %v", tt.pattern, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "regex" { + t.Errorf("Regex(%q).Validate(%v) wrong error: %v", tt.pattern, tt.value, err) + } + } +} + +func TestRegexInvalidPattern(t *testing.T) { + err := Regex(`[invalid`).Validate("test") + if err == nil { + t.Fatal("expected error for invalid pattern, got nil") + } + var syntaxErr RuleSyntaxError + if !errors.As(err, &syntaxErr) { + t.Errorf("expected RuleSyntaxError, got %T: %v", err, err) + } +} + +func TestNotRegex(t *testing.T) { + tests := []struct { + value any + pattern string + wantErr bool + }{ + {"nospaces", `\s`, false}, + {"has spaces", `\s`, true}, + {"hello", `\d`, false}, + {"hello1", `\d`, true}, + {"", `\s`, false}, + {nil, `\s`, true}, + {42, `\s`, true}, + } + for _, tt := range tests { + err := NotRegex(tt.pattern).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("NotRegex(%q).Validate(%v) error = %v, wantErr %v", tt.pattern, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "not_regex" { + t.Errorf("NotRegex(%q).Validate(%v) wrong error: %v", tt.pattern, tt.value, err) + } + } +} + +func TestStartsWith(t *testing.T) { + tests := []struct { + value any + prefix string + wantErr bool + }{ + {"SKU-001", "SKU-", false}, + {"001-SKU", "SKU-", true}, + {"SKU-", "SKU-", false}, + {"", "SKU-", true}, + {"", "", false}, + {nil, "SKU-", true}, + {42, "SKU-", true}, + } + for _, tt := range tests { + err := StartsWith(tt.prefix).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("StartsWith(%q).Validate(%v) error = %v, wantErr %v", tt.prefix, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "starts_with" { + t.Errorf("StartsWith(%q).Validate(%v) wrong error: %v", tt.prefix, tt.value, err) + } + } +} + +func TestEndsWith(t *testing.T) { + tests := []struct { + value any + suffix string + wantErr bool + }{ + {"main.go", ".go", false}, + {"main.js", ".go", true}, + {".go", ".go", false}, + {"", ".go", true}, + {"", "", false}, + {nil, ".go", true}, + {42, ".go", true}, + } + for _, tt := range tests { + err := EndsWith(tt.suffix).Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("EndsWith(%q).Validate(%v) error = %v, wantErr %v", tt.suffix, tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "ends_with" { + t.Errorf("EndsWith(%q).Validate(%v) wrong error: %v", tt.suffix, tt.value, err) + } + } +} + +func TestLowercase(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello", false}, + {"hello world", false}, + {"hello123", false}, + {"Hello", true}, + {"HELLO", true}, + {"hEllo", true}, + {"", false}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Lowercase.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Lowercase.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "lowercase" { + t.Errorf("Lowercase.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestUppercase(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"HELLO", false}, + {"HELLO WORLD", false}, + {"HELLO123", false}, + {"Hello", true}, + {"hello", true}, + {"HELLo", true}, + {"", false}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Uppercase.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Uppercase.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "uppercase" { + t.Errorf("Uppercase.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestASCII(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello", false}, + {"hello world", false}, + {"abc123!@#", false}, + {"\t\n\r", false}, + {"café", true}, + {"Ünïcödé", true}, + {"", false}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := ASCII.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("ASCII.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "ascii" { + t.Errorf("ASCII.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestBase64(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"aGVsbG8=", false}, // "hello" + {"dGVzdA==", false}, // "test" + {"", false}, // empty is valid base64 + {"aGVsbG8", true}, // missing padding + {"not-base64!", true}, + {"aGVsbG8===", true}, // bad padding + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Base64.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Base64.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "base64" { + t.Errorf("Base64.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestContains(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello world", false}, + {"hello", false}, + {"world", true}, + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Contains("hello").Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Contains(\"hello\").Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "contains" { + t.Errorf("Contains.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestCreditCard(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"4111111111111111", false}, // Visa test + {"4111-1111-1111-1111", false}, // dashes stripped + {"4111 1111 1111 1111", false}, // spaces stripped + {"5500005555555559", false}, // Mastercard test + {"378282246310005", false}, // Amex test (15 digits) + {"1234567890123456", true}, // invalid Luhn + {"411111111111111", true}, // too short (15 chars but bad Luhn) + {"", true}, + {"not-a-card", true}, + {nil, true}, + {4111111111111111, true}, // not a string + } + for _, tt := range tests { + err := CreditCard.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("CreditCard.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "credit_card" { + t.Errorf("CreditCard.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestHexColor(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"#fff", false}, + {"#FFF", false}, + {"#FF5733", false}, + {"#aabbcc", false}, + {"FF5733", true}, // missing # + {"#GGG", true}, // invalid hex digits + {"#12345", true}, // 5 digits + {"#1234567", true}, // 7 digits + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := HexColor.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("HexColor.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "hex_color" { + t.Errorf("HexColor.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestJSON(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {`{"key":"value"}`, false}, + {`[1,2,3]`, false}, + {`null`, false}, + {`"hello"`, false}, + {`123`, false}, + {`true`, false}, + {`false`, false}, + {`{invalid}`, true}, + {``, true}, + {`{`, true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := JSON.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("JSON.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "json" { + t.Errorf("JSON.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestJWT(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", false}, + {"eyJ.eyJ.sig", false}, + {"a.b.c", false}, + {"notajwt", true}, + {"a.b", true}, // only two segments + {"a.b.c.d", true}, // four segments + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := JWT.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("JWT.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "jwt" { + t.Errorf("JWT.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestPhoneE164(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"+14155552671", false}, + {"+441234567890", false}, + {"+1", true}, // too short (only 1 digit after +) + {"14155552671", true}, // missing + + {"+0123456789", true}, // country code starts with 0 + {"+1234567890123456", true}, // too long (16 digits) + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := PhoneE164.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("PhoneE164.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "phone_e164" { + t.Errorf("PhoneE164.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestSemver(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"1.0.0", false}, + {"0.0.1", false}, + {"v1.0.0", false}, // v prefix accepted + {"v2.1.3-alpha.1", false}, + {"2.1.3-alpha.1", false}, + {"1.0.0+build.123", false}, // build metadata accepted + {"1.0.0-beta.1+exp.sha.5114f85", false}, + {"1.0", true}, // missing patch + {"1.0.0.0", true}, // extra component + {"01.0.0", true}, // leading zero + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Semver.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Semver.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "semver" { + t.Errorf("Semver.Validate(%v) wrong error: %v", tt.value, err) + } + } +} + +func TestSlug(t *testing.T) { + tests := []struct { + value any + wantErr bool + }{ + {"hello", false}, + {"hello-world", false}, + {"my-post-123", false}, + {"abc", false}, + {"Hello-World", true}, // uppercase + {"-leading", true}, // leading hyphen + {"trailing-", true}, // trailing hyphen + {"double--dash", true}, // consecutive hyphens + {"hello world", true}, // space + {"", true}, + {nil, true}, + {42, true}, + } + for _, tt := range tests { + err := Slug.Validate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Slug.Validate(%v) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + if err != nil && errorCode(err) != "slug" { + t.Errorf("Slug.Validate(%v) wrong error: %v", tt.value, err) + } + } +} diff --git a/translation/translation.go b/translation/translation.go deleted file mode 100644 index e48bc46..0000000 --- a/translation/translation.go +++ /dev/null @@ -1,47 +0,0 @@ -package translation - -import "github.com/behzadsh/go.localization" - -// TranslateFunc is a function type that translate the given key in given locale. -type TranslateFunc func(local, key string, params ...map[string]string) string - -var defaultTranslationFunc TranslateFunc - -func init() { - translator, _ := lang.NewTranslator(lang.DefaultConfigs()) //nolint:errcheck // no need to check error - defaultTranslationFunc = translator.TranslateBy -} - -// SetDefaultTranslatorFunc sets the default translation function. -func SetDefaultTranslatorFunc(fn TranslateFunc) { - defaultTranslationFunc = fn -} - -// GetDefaultTranslatorFunc returns the default translation function. -func GetDefaultTranslatorFunc() TranslateFunc { - return defaultTranslationFunc -} - -// TranslatableRule is an interface for rules that translate the validation -// error messages. -type TranslatableRule interface { - AddLocale(locale string) - AddTranslationFunction(fn TranslateFunc) -} - -// BaseTranslatableRule is a semi-abstract struct that do the basic translation -// functionality. -type BaseTranslatableRule struct { - Translate TranslateFunc - Locale string -} - -// AddLocale adds the default locale for translation. -func (b *BaseTranslatableRule) AddLocale(locale string) { - b.Locale = locale -} - -// AddTranslationFunction adds the translation function to the base. -func (b *BaseTranslatableRule) AddTranslationFunction(fn TranslateFunc) { - b.Translate = fn -} diff --git a/translation/translation_test.go b/translation/translation_test.go deleted file mode 100644 index 1035ee4..0000000 --- a/translation/translation_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package translation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSetDefaultTranslatorFunc(t *testing.T) { - defaultTranslationFunc = nil - SetDefaultTranslatorFunc(func(local, key string, params ...map[string]string) string { - return "it works!" - }) - - assert.Equal(t, "it works!", defaultTranslationFunc("", "")) - defaultTranslationFunc = nil -} - -func TestGetDefaultTranslatorFunc(t *testing.T) { - assert.Nil(t, GetDefaultTranslatorFunc()) - - SetDefaultTranslatorFunc(func(local, key string, params ...map[string]string) string { - return "it works!" - }) - - assert.Equal(t, "it works!", GetDefaultTranslatorFunc()("", "")) -} - -func TestBaseTranslatableRule_AddLocale(t *testing.T) { - r := &BaseTranslatableRule{} - - r.AddLocale("en") - assert.Equal(t, "en", r.Locale) -} - -func TestBaseTranslatableRule_AddTranslationFunction(t *testing.T) { - r := &BaseTranslatableRule{} - r.AddTranslationFunction(func(local, key string, params ...map[string]string) string { - return "it works!" - }) - - assert.Equal(t, "it works!", r.Translate("", "")) -} diff --git a/validation.go b/validation.go deleted file mode 100644 index a8a9083..0000000 --- a/validation.go +++ /dev/null @@ -1,161 +0,0 @@ -package validation - -import ( - "fmt" - "reflect" - "strings" - - "github.com/spf13/cast" - - "github.com/behzadsh/go.validator/bag" - "github.com/behzadsh/go.validator/rules" -) - -// RulesMap is a custom type for a map of rules for field selectors. -type RulesMap map[string][]string - -// ValidateMap validates each field in the provided input map according to the specified set of rules in rulesMap. -// It returns a Result containing any validation errors found. The optional locale parameter specifies the language -// used for error messages; if not provided, the default locale is used. -func ValidateMap(input map[string]any, rulesMap RulesMap, locale ...string) Result { - currentLocale := defaultLocale - if len(locale) > 0 { - currentLocale = locale[0] - } - - inputBag := bag.InputBag(input) - - return doValidation(inputBag, rulesMap, currentLocale) -} - -// ValidateMapSlice iterates over a slice of input maps, validating each map against the specified rulesMap. -// It returns a Result containing accumulated validation errors for all maps, indexed by their position in the slice. -// The optional locale parameter determines the language for error messages; if not set, the default locale is used. -func ValidateMapSlice(input []map[string]any, rulesMap RulesMap, locale ...string) Result { - currentLocale := defaultLocale - if len(locale) > 0 { - currentLocale = locale[0] - } - - result := NewResult() - for i, m := range input { - inputBag := bag.InputBag(m) - tmpResult := doValidation(inputBag, rulesMap, currentLocale) - for key, messages := range tmpResult.Errors { - result.addError(fmt.Sprintf("%d.%s", i, key), messages...) - } - } - - return result -} - -// ValidateStruct validates each field in the provided struct according to the specified set of rules in rulesMap. -// It returns a Result containing any validation errors found. The optional locale parameter specifies the language -// used for error messages; if not provided, the default locale is used. -func ValidateStruct(input any, rulesMap RulesMap, locale ...string) Result { - currentLocale := defaultLocale - if len(locale) > 0 { - currentLocale = locale[0] - } - - v := reflect.ValueOf(input) - if v.Kind() != reflect.Struct && (v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct) { - panic("validation.ValidateStruct only support struct or a pointer to a struct as first parameter") - } - - inputBag := bag.NewInputBagFromStruct(input) - - return doValidation(inputBag, rulesMap, currentLocale) -} - -// ValidateStructSlice iterates over a slice of structs, validating each struct against the specified rulesMap. -// It returns a Result containing accumulated validation errors for all structs, indexed by their position in the slice. -// The optional locale parameter determines the language for error messages; if not set, the default locale is used. -func ValidateStructSlice(input []any, rulesMap RulesMap, locale ...string) Result { - currentLocale := defaultLocale - if len(locale) > 0 { - currentLocale = locale[0] - } - - result := NewResult() - for i, strct := range input { - tmpResult := ValidateStruct(strct, rulesMap, currentLocale) - for key, messages := range tmpResult.Errors { - result.addError(fmt.Sprintf("%d.%s", i, key), messages...) - } - } - - return result -} - -// Validate validates the provided input according to the specified set of rules in ruleSlice. -// It returns a Result containing any validation errors found. The optional locale parameter specifies the language -// used for error messages; if not provided, the default locale is used. -func Validate(input any, ruleSlice []string, locale ...string) Result { - currentLocale := defaultLocale - if len(locale) > 0 { - currentLocale = locale[0] - } - - return doValidation(bag.InputBag{"variable": input}, RulesMap{"variable": ruleSlice}, currentLocale) -} - -func doValidation(inputBag bag.InputBag, rulesMap RulesMap, locale string) Result { - explicitRules := make(RulesMap) - - for fieldSelector, fieldRules := range rulesMap { - for _, explicitFieldSelector := range normalizeFieldSelector(fieldSelector, inputBag) { - explicitRules[explicitFieldSelector] = fieldRules - } - } - - result := NewResult() - for selector, selectorRules := range explicitRules { - val, exists := inputBag.Get(selector) - for _, ruleStr := range selectorRules { - ruleName := ruleIndicator(ruleStr) - rule := ruleName.load(locale) - - if !exists { - fr, ok := rule.(rules.FieldRequiredRule) - if !ok || !fr.RequiresField() { - continue - } - } - - ruleResult := rule.Validate(selector, val, inputBag) - if ruleResult.Failed() { - result.addError(selector, ruleResult.Message()) - if stopOnFirstFailure { - break - } - } - } - } - - return result -} - -func normalizeFieldSelector(selector string, input bag.InputBag) []string { - if !strings.Contains(selector, ".*") { - return []string{selector} - } - - parts := strings.SplitN(selector, ".*", 2) - total := 0 - val, ok := input.Get(parts[0]) - if ok { - temp, err := cast.ToSliceE(val) - if err == nil { - total = len(temp) - } - } - - var explicitSelectors []string - for i := 0; i < total; i++ { - key := fmt.Sprintf("%s.%d%s", parts[0], i, parts[1]) - explicitSelectors = append(explicitSelectors, normalizeFieldSelector(key, input)...) - } - - return explicitSelectors -} diff --git a/validation_test.go b/validation_test.go deleted file mode 100644 index c00108a..0000000 --- a/validation_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func resetConfigs() { - registerDefaultRules() - - defaultLocale = "en" - stopOnFirstFailure = false -} - -func TestValidateMap(t *testing.T) { - resetConfigs() - t.Run("success", func(t *testing.T) { - data := map[string]any{ - "email": "user@example.com", - "password": "mySecurePassword", - } - - res := ValidateMap(data, RulesMap{ - "email": {"required", "email"}, - "password": {"required", "string"}, - }, "en") - - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("failed", func(t *testing.T) { - data := map[string]any{ - "email": "invalidEmail", - } - - res := ValidateMap(data, RulesMap{ - "email": {"required", "email"}, - "password": {"required", "string"}, - }) - - assert.True(t, res.Failed()) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("email")) - assert.Len(t, res.Errors["email"], 1) - assert.Equal(t, "validation.email", res.Errors["email"][0]) - assert.True(t, res.Errors.Has("password")) - assert.Len(t, res.Errors["password"], 1) - assert.Equal(t, "validation.required", res.Errors["password"][0]) - }) -} - -func TestValidateMapSlice(t *testing.T) { - resetConfigs() - t.Run("success", func(t *testing.T) { - data := []map[string]any{ - { - "email": "user@example.com", - "password": "mySecurePassword", - }, - { - "email": "user2@example.com", - "password": "mySecurePa$$word", - }, - } - - res := ValidateMapSlice(data, RulesMap{ - "email": {"required", "email"}, - "password": {"required", "string"}, - }, "en") - - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("failed", func(t *testing.T) { - data := []map[string]any{ - { - "email": "invalidEmail", - }, - { - "password": false, - }, - } - - res := ValidateMapSlice(data, RulesMap{ - "email": {"required", "email"}, - "password": {"required", "string"}, - }) - - assert.True(t, res.Failed()) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("0.email")) - assert.Len(t, res.Errors["0.email"], 1) - assert.Equal(t, "validation.email", res.Errors["0.email"][0]) - assert.True(t, res.Errors.Has("0.password")) - assert.Len(t, res.Errors["0.password"], 1) - assert.Equal(t, "validation.required", res.Errors["0.password"][0]) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("1.email")) - assert.Len(t, res.Errors["1.email"], 1) - assert.Equal(t, "validation.required", res.Errors["1.email"][0]) - assert.True(t, res.Errors.Has("1.password")) - assert.Len(t, res.Errors["1.password"], 1) - assert.Equal(t, "validation.string", res.Errors["1.password"][0]) - }) -} - -func TestValidateStruct(t *testing.T) { - resetConfigs() - t.Run("success", func(t *testing.T) { - data := struct { - Title string `json:"title"` - Desc string `json:"description"` - }{ - Title: "My Title", - Desc: "This is a sample description for testing a struct data.", - } - - res := ValidateStruct(data, RulesMap{ - "title": {"required", "string", "maxLength:50"}, - "description": {"required", "string", "minLength:50"}, - }, "en") - - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("failed", func(t *testing.T) { - data := struct { - Title string `json:"title"` - Desc string `json:"description"` - }{ - Title: "This is a sample description for testing a struct data.", - Desc: "My Title", - } - - res := ValidateStruct(data, RulesMap{ - "title": {"required", "string", "maxLength:50"}, - "description": {"required", "string", "minLength:50"}, - }, "en") - - assert.True(t, res.Failed()) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("title")) - assert.Len(t, res.Errors["title"], 1) - assert.Equal(t, "validation.max_length", res.Errors["title"][0]) - assert.True(t, res.Errors.Has("description")) - assert.Len(t, res.Errors["description"], 1) - assert.Equal(t, "validation.min_length", res.Errors["description"][0]) - }) - - t.Run("panic", func(t *testing.T) { - data := map[string]any{ - "title": "My Title", - "description": "This is a sample description for testing a struct data.", - } - - assert.Panics(t, func() { - ValidateStruct(data, RulesMap{ - "title": {"required", "string", "maxLength:50"}, - "description": {"required", "string", "minLength:50"}, - }) - }) - }) -} - -func TestValidateStructSlice(t *testing.T) { - resetConfigs() - t.Run("success", func(t *testing.T) { - data := []any{ - struct { - Title string `json:"title"` - Desc string `json:"description"` - }{ - Title: "My Title", - Desc: "This is a sample description for testing a struct data.", - }, - struct { - Title string `json:"title"` - Desc string `json:"description"` - }{ - Title: "Another Title", - Desc: "This is another sample description for testing a struct data.", - }, - } - - res := ValidateStructSlice(data, RulesMap{ - "title": {"required", "string", "maxLength:50"}, - "description": {"required", "string", "minLength:50"}, - }, "en") - - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("failed", func(t *testing.T) { - data := []any{ - struct { - Title string `json:"title"` - Desc string `json:"description"` - }{ - Title: "This is a sample description for testing a struct data.", - Desc: "My Title", - }, - struct { - Title string `json:"title"` - Desc string `json:"description"` - }{ - Title: "My Title", - }, - } - - res := ValidateStructSlice(data, RulesMap{ - "title": {"notEmpty", "string", "maxLength:50"}, - "description": {"notEmpty", "string", "minLength:50"}, - }, "en") - - assert.True(t, res.Failed()) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("0.title")) - assert.Len(t, res.Errors["0.title"], 1) - assert.Equal(t, "validation.max_length", res.Errors["0.title"][0]) - assert.True(t, res.Errors.Has("0.description")) - assert.Len(t, res.Errors["0.description"], 1) - assert.Equal(t, "validation.min_length", res.Errors["0.description"][0]) - assert.False(t, res.Errors.Has("1.title")) - assert.True(t, res.Errors.Has("1.description")) - assert.Len(t, res.Errors["1.description"], 2) - assert.Equal(t, "validation.not_empty", res.Errors["1.description"][0]) - assert.Equal(t, "validation.min_length", res.Errors["1.description"][1]) - }) - - t.Run("panic", func(t *testing.T) { - data := map[string]any{ - "title": "My Title", - "description": "This is a sample description for testing a struct data.", - } - - assert.Panics(t, func() { - ValidateStruct(data, RulesMap{ - "title": {"required", "string", "maxLength:50"}, - "description": {"required", "string", "minLength:50"}, - }) - }) - }) -} - -func TestValidate(t *testing.T) { - resetConfigs() - t.Run("success", func(t *testing.T) { - data := "fa7a0f59-7c7c-4c51-b2d5-32964a090eac" - - res := Validate(data, []string{"uuid"}) - - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("failed", func(t *testing.T) { - data := 10 - - res := Validate(data, []string{"string", "uuid"}, "en") - - assert.True(t, res.Failed()) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("variable")) - assert.Equal(t, "validation.string", res.Errors["variable"][0]) - assert.Equal(t, "validation.uuid", res.Errors["variable"][1]) - }) -} - -func TestSpecialValidation(t *testing.T) { - resetConfigs() - t.Run("nestedValidation", func(t *testing.T) { - data := map[string]any{ - "map": map[string]any{ - "field1": "value1", - "field2": "value2", - }, - "array": []any{"val1", "val2"}, - "mapArray": []map[string]any{ - { - "field1": "value1", - "field2": "value2", - }, - { - "field1": "value3", - "field2": "value4", - }, - }, - } - - res := ValidateMap(data, RulesMap{ - "map.field1": {"required"}, - "array.*": {"string"}, - "mapArray.*.field2": {"required"}, - }) - - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("stopOnFailure", func(t *testing.T) { - StopOnFirstFailure() - data := map[string]any{ - "password": "mySecurePassword", - } - - res := ValidateMap(data, RulesMap{ - "email": {"required", "email"}, - "password": {"required", "string"}, - }) - - assert.True(t, res.Failed()) - assert.NotEmpty(t, res.Errors) - assert.True(t, res.Errors.Has("email")) - assert.Len(t, res.Errors["email"], 1) - assert.Equal(t, "validation.required", res.Errors["email"][0]) - }) - - t.Run("insufficientParam", func(t *testing.T) { - resetConfigs() - data := map[string]any{ - "code": 13839, - } - - assert.Panics(t, func() { - ValidateMap(data, RulesMap{ - "code": {"digitsBetween:5"}, - }) - }) - }) - - t.Run("unregisteredRule", func(t *testing.T) { - resetConfigs() - data := map[string]any{ - "code": 13839, - } - - assert.Panics(t, func() { - ValidateMap(data, RulesMap{ - "code": {"unregistered:10"}, - }) - }) - }) -} - -func TestValidationSkippingBehavior(t *testing.T) { - resetConfigs() - - t.Run("skipNonRequiredWhenMissing", func(t *testing.T) { - data := map[string]any{} - - res := ValidateMap(data, RulesMap{ - "email": {"email"}, - "password": {"string"}, - "age": {"integer"}, - }) - - // Non-required rules should be skipped when field is missing - assert.False(t, res.Failed()) - assert.Empty(t, res.Errors) - }) - - t.Run("skipNonRequiredWhenEmpty", func(t *testing.T) { - data := map[string]any{ - "email": "", - "password": nil, - "age": 0, - } - - res := ValidateMap(data, RulesMap{ - "email": {"email"}, - "password": {"string"}, - "age": {"integer"}, - }) - - // Non-required rules should still validate when field exists but is empty - // (they only skip when field doesn't exist) - assert.True(t, res.Failed()) - assert.True(t, res.Errors.Has("email")) - }) - - t.Run("requiredRulesStillValidateWhenMissing", func(t *testing.T) { - data := map[string]any{} - - res := ValidateMap(data, RulesMap{ - "email": {"required"}, - "password": {"required"}, - }) - - // Required rules should still validate even when field is missing - assert.True(t, res.Failed()) - assert.True(t, res.Errors.Has("email")) - assert.True(t, res.Errors.Has("password")) - }) - - t.Run("requiredRulesStillValidateWhenEmpty", func(t *testing.T) { - data := map[string]any{ - "email": "", - } - - res := ValidateMap(data, RulesMap{ - "email": {"notEmpty"}, - "password": {"required"}, - }) - - // Required rules (notEmpty, required) should still validate even when field is empty - assert.True(t, res.Failed()) - assert.True(t, res.Errors.Has("email")) - assert.True(t, res.Errors.Has("password")) - }) - - t.Run("mixedRequiredAndNonRequired", func(t *testing.T) { - data := map[string]any{ - "name": "John", - } - - res := ValidateMap(data, RulesMap{ - "name": {"required", "string", "minLength:10"}, - "email": {"email"}, - "password": {"string"}, - }) - - // Required rule should validate (minLength fails), non-required rules should be skipped - assert.True(t, res.Failed()) - assert.True(t, res.Errors.Has("name")) - assert.False(t, res.Errors.Has("email")) - assert.False(t, res.Errors.Has("password")) - }) - - t.Run("nonRequiredValidatesWhenFieldExistsAndNotEmpty", func(t *testing.T) { - data := map[string]any{ - "email": "invalid-email", - } - - res := ValidateMap(data, RulesMap{ - "email": {"email"}, - }) - - // Non-required rule should validate when field exists and is not empty - assert.True(t, res.Failed()) - assert.True(t, res.Errors.Has("email")) - }) -} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000..17fac8f --- /dev/null +++ b/validator.go @@ -0,0 +1,121 @@ +// Package validation provides a small, schema-based validator for map and struct input. +// +// Build a schema by chaining Field calls, then call Validate. Each rule is an independent value; the only rule that +// fails on an absent field is Required. +// +// schema := validation.New(). +// Field("name", validation.Required, validation.MinLength(2)). +// Field("email", validation.Required, validation.Email) +// +// if res, err := schema.Validate(input); err != nil { +// log.Fatal(err) // RuleSyntaxError: misconfigured rule, fix at startup +// } else if res.HasErrors() { +// for _, e := range res.Errors() { +// fmt.Println(e.Path, e.Code, e.Params) +// } +// } +// +// The input may be a map[string]any, a struct, or a pointer to a struct. +// Field names in the path follow this resolution order: the first comma-segment of the `json` tag (when present and +// not "-"), then the exported field name. +package validation + +import "errors" + +// Rule validates a single value and returns nil on success or an error describing the failure. +type Rule interface { + Validate(value any) error +} + +// RuleFunc adapts a plain function to the Rule interface. +type RuleFunc func(value any) error + +// Validate satisfies the Rule interface. +func (f RuleFunc) Validate(value any) error { return f(value) } + +// InputRule is a Rule that also receives the full validated input so it can reference other fields. Use this for +// cross-field rules such as SameAs. +type InputRule interface { + Rule + ValidateWithInput(value any, input *InputBag) error +} + +// InputRuleFunc adapts a plain function to the InputRule interface. +type InputRuleFunc func(value any, input *InputBag) error + +// Validate satisfies the Rule interface by calling the function with a nil input. +// Schema.Validate always calls ValidateWithInput instead. +func (f InputRuleFunc) Validate(value any) error { return f(value, nil) } + +// ValidateWithInput satisfies the InputRule interface. +func (f InputRuleFunc) ValidateWithInput(value any, input *InputBag) error { return f(value, input) } + +// Schema describes a set of fields and the rules that apply to each. +// +// A Schema is intended to be fully built up before Validate is called. Once built, Validate is safe to call from +// multiple goroutines concurrently. +type Schema struct { + fields []fieldRules +} + +type fieldRules struct { + path string + rules []Rule +} + +// New returns an empty Schema ready to be populated via Field. +func New() *Schema { + return &Schema{} +} + +// Field appends a list of rules for the given dot-notation path. +// +// Field returns the receiver to support chaining. +func (s *Schema) Field(path string, rules ...Rule) *Schema { + s.fields = append(s.fields, fieldRules{path: path, rules: rules}) + + return s +} + +// Validate runs every rule against its corresponding field in the input and returns the collected errors. +// +// The input may be a map[string]any, a struct, a pointer to a struct, or any nested combination thereof. The returned +// slice is empty (length zero) when validation succeeds. All rules for a field are executed; validation does not stop +// at the first failure. +func (s *Schema) Validate(input any) (*Result, error) { + var errs []FieldError + inputBag := NewInputBag(input) + + for _, f := range s.fields { + value, _ := inputBag.Lookup(f.path) + for _, r := range f.rules { + var err error + if ir, ok := r.(InputRule); ok { + err = ir.ValidateWithInput(value, inputBag) + } else { + err = r.Validate(value) + } + if err != nil { + var rse RuleSyntaxError + if errors.As(err, &rse) { + return nil, rse + } + code, params := codeAndParams(err) + errs = append( + errs, + FieldError{Path: f.path, Err: err, Message: err.Error(), Code: code, Params: params}, + ) + } + } + } + + return &Result{errors: errs}, nil +} + +func codeAndParams(err error) (string, map[string]any) { + var ve Error + if errors.As(err, &ve) { + return ve.Code(), ve.Params() + } + return "", nil +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..f3c7687 --- /dev/null +++ b/validator_test.go @@ -0,0 +1,254 @@ +package validation + +import ( + "sync" + "testing" +) + +func TestSchemaValidate_Map(t *testing.T) { + schema := New(). + Field("name", Required, MinLength(2)). + Field("email", Required, Email). + Field("age", Min[int](18)) + + t.Run( + "valid input", func(t *testing.T) { + input := map[string]any{ + "name": "Alice", + "email": "alice@example.com", + "age": 25, + } + res, err := schema.Validate(input) + if err != nil { + t.Fatal(err) + } + if res.HasErrors() { + t.Errorf("expected no errors, got: %v", res.Errors()) + } + }, + ) + + t.Run( + "missing required fields", func(t *testing.T) { + input := map[string]any{} + res, err := schema.Validate(input) + if err != nil { + t.Fatal(err) + } + if !res.HasErrors() { + t.Fatal("expected errors") + } + if len(res.For("name")) == 0 { + t.Error("expected error for name") + } + if len(res.For("email")) == 0 { + t.Error("expected error for email") + } + }, + ) + + t.Run( + "invalid email", func(t *testing.T) { + input := map[string]any{ + "name": "Alice", + "email": "not-an-email", + "age": 25, + } + res, err := schema.Validate(input) + if err != nil { + t.Fatal(err) + } + emailErrs := res.For("email") + if len(emailErrs) == 0 { + t.Error("expected email error") + } + }, + ) +} + +func TestSchemaValidate_Struct(t *testing.T) { + type User struct { + Name string `json:"name"` + Email string `json:"email"` + } + + schema := New(). + Field("name", Required). + Field("email", Required, Email) + + t.Run( + "valid struct", func(t *testing.T) { + res, err := schema.Validate(User{Name: "Bob", Email: "bob@example.com"}) + if err != nil { + t.Fatal(err) + } + if res.HasErrors() { + t.Errorf("unexpected errors: %v", res.Errors()) + } + }, + ) + + t.Run( + "empty struct", func(t *testing.T) { + res, err := schema.Validate(User{}) + if err != nil { + t.Fatal(err) + } + if !res.HasErrors() { + t.Fatal("expected errors") + } + if len(res.For("name")) == 0 { + t.Error("expected error for name") + } + if len(res.For("email")) == 0 { + t.Error("expected error for email") + } + }, + ) + + t.Run( + "pointer to struct", func(t *testing.T) { + res, err := schema.Validate(&User{Name: "Carol", Email: "carol@example.com"}) + if err != nil { + t.Fatal(err) + } + if res.HasErrors() { + t.Errorf("unexpected errors: %v", res.Errors()) + } + }, + ) + + t.Run( + "nil pointer to struct", func(t *testing.T) { + var u *User + res, err := schema.Validate(u) + if err != nil { + t.Fatal(err) + } + if !res.HasErrors() { + t.Fatal("expected errors for nil struct pointer") + } + }, + ) +} + +func TestSchemaValidate_MultiError(t *testing.T) { + schema := New(). + Field("email", Required, Email, MinLength(5)) + + res, err := schema.Validate(map[string]any{"email": "x"}) + if err != nil { + t.Fatal(err) + } + + emailErrs := res.For("email") + if len(emailErrs) < 2 { + t.Errorf("expected at least 2 errors for email, got %d", len(emailErrs)) + } +} + +func TestSchemaValidate_NestedPath(t *testing.T) { + schema := New(). + Field("profile.email", Required, Email) + + t.Run( + "valid nested", func(t *testing.T) { + input := map[string]any{ + "profile": map[string]any{"email": "user@example.com"}, + } + res, err := schema.Validate(input) + if err != nil { + t.Fatal(err) + } + if res.HasErrors() { + t.Errorf("unexpected errors: %v", res.Errors()) + } + }, + ) + + t.Run( + "missing nested", func(t *testing.T) { + res, err := schema.Validate(map[string]any{}) + if err != nil { + t.Fatal(err) + } + if len(res.For("profile.email")) == 0 { + t.Error("expected error for profile.email") + } + }, + ) +} + +func TestResult_For(t *testing.T) { + schema := New(). + Field("a", Required). + Field("b", Required) + + res, err := schema.Validate(map[string]any{}) + if err != nil { + t.Fatal(err) + } + + aErrs := res.For("a") + bErrs := res.For("b") + cErrs := res.For("c") + + if len(aErrs) == 0 { + t.Error("expected error for a") + } + if len(bErrs) == 0 { + t.Error("expected error for b") + } + if len(cErrs) != 0 { + t.Errorf("expected no errors for c, got %d", len(cErrs)) + } +} + +func TestFieldError_Code(t *testing.T) { + schema := New().Field("name", Required) + res, err := schema.Validate(map[string]any{}) + if err != nil { + t.Fatal(err) + } + + errs := res.For("name") + if len(errs) == 0 { + t.Fatal("expected error") + } + fe := errs[0] + if fe.Code != "required" { + t.Errorf("FieldError.Code = %q, want \"required\"", fe.Code) + } +} + +func TestSchemaValidate_Concurrent(t *testing.T) { + schema := New(). + Field("name", Required, MinLength(2)). + Field("email", Required, Email) + + input := map[string]any{ + "name": "Alice", + "email": "alice@example.com", + } + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + res, err := schema.Validate(input) + if err != nil || res.HasErrors() { + t.Errorf("concurrent Validate failed: err=%v hasErrors=%v", err, res.HasErrors()) + } + }() + } + wg.Wait() +} + +func TestSchemaField_Chaining(t *testing.T) { + s := New() + s2 := s.Field("a", Required) + if s != s2 { + t.Error("Field() should return receiver for chaining") + } +}