mirror of
https://codeberg.org/readeck/readeck.git
synced 2026-06-18 11:04:36 +00:00
478 lines
13 KiB
Go
478 lines
13 KiB
Go
// SPDX-FileCopyrightText: © 2024 Olivier Meunier <olivier@neokraft.net>
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package forms
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
// ErrRequired is a required field.
|
|
ErrRequired = Gettext("field is required")
|
|
// ErrInvalidEmail is an invalid e-mail address.
|
|
ErrInvalidEmail = Gettext("not a valid email address")
|
|
// ErrInvalidURL is an invalid URL.
|
|
ErrInvalidURL = Gettext("invalid URL")
|
|
)
|
|
|
|
var (
|
|
ctxCleanersKey = &contextKey{"cleaners"}
|
|
ctxValidatorsKey = &contextKey{"validators"}
|
|
ctxChoicesKey = &contextKey{"choices"}
|
|
)
|
|
|
|
var errValidationSkip = errors.New("skip")
|
|
|
|
func applyFieldOptions[T any](f Field, options ...any) {
|
|
var cleaners []ValueCleaner
|
|
var validators []Validator
|
|
|
|
for _, option := range options {
|
|
switch t := option.(type) {
|
|
case FieldOption:
|
|
t(f)
|
|
case ValueCleaner:
|
|
cleaners = append(cleaners, t)
|
|
case FieldValidator, ValueValidator[T]:
|
|
validators = append(validators, t)
|
|
}
|
|
}
|
|
|
|
SetCleaners(f, append(GetCleaners(f), cleaners...)...)
|
|
SetValidators(f, append(GetValidators(f), validators...)...)
|
|
}
|
|
|
|
// ApplyValidators runs the given [FieldValidator] and [ValueValidator] on the field and a value.
|
|
// The value can be of type T of []T. When it's a list ([]T), each item will pass through each
|
|
// [ValueValidator] instance.
|
|
// It returns a list of unwrapped errors.
|
|
func ApplyValidators[T any](f Field, value any, validators ...Validator) (errs []error) {
|
|
for _, validator := range validators {
|
|
var fn func() error
|
|
|
|
switch validator := validator.(type) {
|
|
case FieldValidator:
|
|
// On a [FieldValidator], we just need to run it.
|
|
fn = func() error {
|
|
return validator.ValidateField(f)
|
|
}
|
|
case ValueValidator[T]:
|
|
// On a [ValueValidator], we must run it on each value when it's a list of T.
|
|
switch value := value.(type) {
|
|
case []T:
|
|
fn = func() error {
|
|
res := []error{}
|
|
for _, v := range value {
|
|
res = append(res, validator.ValidateValue(f, v))
|
|
}
|
|
return errors.Join(res...)
|
|
}
|
|
case T:
|
|
fn = func() error {
|
|
return validator.ValidateValue(f, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
if fn != nil {
|
|
err := fn()
|
|
if err == nil {
|
|
continue
|
|
}
|
|
|
|
// We unwrap the error as a list so we can catch
|
|
// any embedded skip or fatal error.
|
|
for _, err := range unwrapErrors(err) {
|
|
if errors.Is(err, errValidationSkip) {
|
|
// skip the rest of the validation
|
|
return
|
|
}
|
|
|
|
errs = append(errs, err)
|
|
if _, ok := err.(*fatalError); ok {
|
|
// stop validation process
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// Validator describes a generic validator.
|
|
// By default, it can be anything but, once attached to a field, relevant
|
|
// interfaces are called during cleanup and validation steps.
|
|
type Validator any
|
|
|
|
// ValueCleaner describes a value cleaner.
|
|
type ValueCleaner interface {
|
|
Clean(v any) any
|
|
}
|
|
|
|
// FieldValidator describes a field validator (not its value).
|
|
type FieldValidator interface {
|
|
ValidateField(f Field) error
|
|
}
|
|
|
|
// ValueValidator describes a value validator.
|
|
type ValueValidator[T any] interface {
|
|
ValidateValue(f Field, v T) error
|
|
}
|
|
|
|
// CleanerFunc is a [ValueCleaner].
|
|
type CleanerFunc func(v any) any
|
|
|
|
// Clean implements [ValueCleaner].
|
|
func (c CleanerFunc) Clean(v any) any {
|
|
return c(v)
|
|
}
|
|
|
|
// FieldValidatorFunc is a [FieldValidator].
|
|
type FieldValidatorFunc func(f Field) error
|
|
|
|
// ValidateField implements [FieldValidator].
|
|
func (c FieldValidatorFunc) ValidateField(f Field) error {
|
|
return c(f)
|
|
}
|
|
|
|
// ValueValidatorFunc is a [ValueValidator].
|
|
type ValueValidatorFunc[T any] func(f Field, v T) error
|
|
|
|
// ValidateValue implements [ValueValidator].
|
|
func (c ValueValidatorFunc[T]) ValidateValue(f Field, v T) error {
|
|
return c(f, v)
|
|
}
|
|
|
|
// SkipValidation returns an error that stops any subsequent validator.
|
|
func SkipValidation() error {
|
|
return errValidationSkip
|
|
}
|
|
|
|
// Contexter describes a contexter getter and setter.
|
|
type Contexter interface {
|
|
Context() context.Context
|
|
SetContext(context.Context)
|
|
}
|
|
|
|
// FieldOption is a function that runs uppon a field's creation.
|
|
type FieldOption func(f Field)
|
|
|
|
// GetCleaners returns the field's [ValueCleaner]s.
|
|
func GetCleaners(f Field) []ValueCleaner {
|
|
v, _ := f.Context().Value(ctxCleanersKey).([]ValueCleaner)
|
|
return v
|
|
}
|
|
|
|
// SetCleaners sets a list of [ValueCleaner] to a field.
|
|
func SetCleaners(f Field, cleaners ...ValueCleaner) {
|
|
f.SetContext(context.WithValue(f.Context(), ctxCleanersKey, cleaners))
|
|
}
|
|
|
|
// GetValidators returns the field's [Validator]s.
|
|
func GetValidators(f Field) []Validator {
|
|
v, _ := f.Context().Value(ctxValidatorsKey).([]Validator)
|
|
return v
|
|
}
|
|
|
|
// SetValidators sets a list of [Validator] to a field.
|
|
func SetValidators(f Field, validators ...Validator) {
|
|
f.SetContext(context.WithValue(f.Context(), ctxValidatorsKey, validators))
|
|
}
|
|
|
|
// Default returns a [FieldOption] that sets a field's default value.
|
|
func Default(v any) FieldOption {
|
|
return func(f Field) {
|
|
f.Set(v)
|
|
}
|
|
}
|
|
|
|
// DefaultFunc returns a [FieldOption] that sets a field's default value
|
|
// using a function.
|
|
func DefaultFunc(fn func(Field) any) FieldOption {
|
|
return func(f Field) {
|
|
f.Set(fn(f))
|
|
}
|
|
}
|
|
|
|
// ConditionValidator is a wrapper that runs different validators based
|
|
// on a provided condition.
|
|
type ConditionValidator[T any] struct {
|
|
condition func(f Field, value T) bool
|
|
whenTrue []Validator
|
|
whenFalse []Validator
|
|
}
|
|
|
|
// When creates a new [ConditionValidator].
|
|
func When[T any](condition func(f Field, value T) bool) *ConditionValidator[T] {
|
|
return &ConditionValidator[T]{condition: condition}
|
|
}
|
|
|
|
// True adds the validators to the "condition true" validators.
|
|
func (c *ConditionValidator[T]) True(validators ...Validator) *ConditionValidator[T] {
|
|
c.whenTrue = append(c.whenTrue, validators...)
|
|
return c
|
|
}
|
|
|
|
// False adds the validators to the "condition false" validators.
|
|
func (c *ConditionValidator[T]) False(validators ...Validator) *ConditionValidator[T] {
|
|
c.whenFalse = append(c.whenFalse, validators...)
|
|
return c
|
|
}
|
|
|
|
// ValidateValue implements [ValueValidator].
|
|
func (c *ConditionValidator[T]) ValidateValue(f Field, v T) error {
|
|
if c.condition(f, v) {
|
|
return errors.Join(ApplyValidators[T](f, v, c.whenTrue...)...)
|
|
}
|
|
|
|
return errors.Join(ApplyValidators[T](f, v, c.whenFalse...)...)
|
|
}
|
|
|
|
// Optional return a [When] validator that runs validators only when the field
|
|
// is not null and not empty.
|
|
func Optional[T any](validators ...Validator) ValueValidator[T] {
|
|
return When(func(f Field, _ T) bool {
|
|
return f.IsNil() || f.IsEmpty() || f.String() == ""
|
|
}).False(validators...)
|
|
}
|
|
|
|
// Trim is a [ValueCleaner] that trims spaces on string values.
|
|
var Trim = CleanerFunc(func(v any) any {
|
|
if v, ok := v.(string); ok {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return v
|
|
})
|
|
|
|
// DiscardEmpty is a [ValueCleaner] that turns an empty string to a nil value.
|
|
// This can be used in [ListField] where nil values are discard and you need to
|
|
// discard empty ones as well.
|
|
var DiscardEmpty = CleanerFunc(func(v any) any {
|
|
if v, ok := v.(string); ok && v == "" {
|
|
return nil
|
|
}
|
|
return v
|
|
})
|
|
|
|
// Required is a [FieldValidator] that returns an error when a field is null, not bound or empty.
|
|
var Required = FieldValidatorFunc(func(f Field) error {
|
|
if !f.IsBound() || f.IsEmpty() || f.IsNil() {
|
|
return FatalError(ErrRequired)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// RequiredOrNil is a [FieldValidator] that returns an error when the field is empty but not null.
|
|
var RequiredOrNil = FieldValidatorFunc(func(f Field) error {
|
|
if !f.IsNil() && f.IsEmpty() {
|
|
return FatalError(ErrRequired)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Skip skips subsequent validators when the field is null or empty.
|
|
var Skip = FieldValidatorFunc(func(f Field) error {
|
|
if f.IsNil() || f.IsEmpty() || f.String() == "" {
|
|
return SkipValidation()
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// TypedValidator is a helper function that returns a [ValueValidator] from a
|
|
// validation function and an error message.
|
|
func TypedValidator[T any](validator func(T) bool, err error) ValueValidator[T] {
|
|
return ValueValidatorFunc[T](func(f Field, v T) error {
|
|
if f.IsNil() {
|
|
return nil
|
|
}
|
|
|
|
if !validator(v) {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// IsEmail performs a rough check of the email address. That is, it
|
|
// only checks for the presence of "@", only once and in the string.
|
|
// It also rejects addresses with an ip host.
|
|
var IsEmail = ValueValidatorFunc[string](func(f Field, v string) error {
|
|
if f.IsNil() {
|
|
return nil
|
|
}
|
|
|
|
if strings.Count(v, "@") != 1 || strings.HasPrefix(v, "@") || strings.HasSuffix(v, "@") {
|
|
return ErrInvalidEmail
|
|
}
|
|
|
|
_, host, _ := strings.Cut(v, "@")
|
|
if _, err := netip.ParseAddr(host); err == nil {
|
|
return ErrInvalidEmail
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// IsURL checks that the input value is a valid URL
|
|
// and matches the given schemes.
|
|
func IsURL(schemes ...string) ValueValidator[string] {
|
|
return TypedValidator(func(v string) bool {
|
|
u, err := url.Parse(v)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if !slices.Contains(schemes, u.Scheme) {
|
|
return false
|
|
}
|
|
return u.Hostname() != ""
|
|
}, ErrInvalidURL)
|
|
}
|
|
|
|
// Gte is an integer validator that checks
|
|
// if a value is greater or equal than a parameter.
|
|
func Gte(value int) ValueValidator[int] {
|
|
return TypedValidator(func(v int) bool {
|
|
return v >= value
|
|
}, Gettext("must be greater or equal than %d", value))
|
|
}
|
|
|
|
// Lte is an integer validator that checks
|
|
// if a value is lower or equal than a parameter.
|
|
func Lte(value int) ValueValidator[int] {
|
|
return TypedValidator(func(v int) bool {
|
|
return v <= value
|
|
}, Gettext("must be lower or equal than %d", value))
|
|
}
|
|
|
|
// MinLen is a string validator thats checks
|
|
// if it contains at least n characters.
|
|
func MinLen(n int) ValueValidator[string] {
|
|
return TypedValidator(func(s string) bool {
|
|
return len([]rune(s)) >= n
|
|
}, Gettext("text must contain at least %d characters", n))
|
|
}
|
|
|
|
// MaxLen is a string validator thats checks
|
|
// if it contains at most n characters.
|
|
func MaxLen(n int) ValueValidator[string] {
|
|
return TypedValidator(func(s string) bool {
|
|
return len([]rune(s)) <= n
|
|
}, Gettext("text must contain at most %d characters", n))
|
|
}
|
|
|
|
// Len is a string validator thats checks
|
|
// if it contains exactly n characters.
|
|
func Len(n int) ValueValidator[string] {
|
|
return TypedValidator(func(s string) bool {
|
|
return len([]rune(s)) == n
|
|
}, Gettext("text must contain %d characters", n))
|
|
}
|
|
|
|
// SplitLines works on [ListField] (of string) and populates
|
|
// its values after spliting each line.
|
|
// It will trim spaces on each value.
|
|
var SplitLines = FieldValidatorFunc(func(f Field) error {
|
|
field, ok := f.(*ListField[string])
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
res := []string{}
|
|
for _, x := range field.V() {
|
|
for l := range strings.Lines(x) {
|
|
if strings.TrimSpace(l) != "" {
|
|
res = append(res, strings.TrimSpace(l))
|
|
}
|
|
}
|
|
}
|
|
field.value.V = res
|
|
return nil
|
|
})
|
|
|
|
// ValueChoice is a key/value pair.
|
|
type ValueChoice[T comparable] struct {
|
|
Name string
|
|
Value T
|
|
}
|
|
|
|
// In returns true when the choice is present in a list of values.
|
|
func (c ValueChoice[T]) In(values []T) bool {
|
|
return slices.ContainsFunc(values, func(x T) bool {
|
|
return x == c.Value
|
|
})
|
|
}
|
|
|
|
// ValueChoices is a list of [ValueChoice].
|
|
type ValueChoices[T comparable] []ValueChoice[T]
|
|
|
|
func (c ValueChoices[T]) String() string {
|
|
res := make([]string, len(c))
|
|
for i, x := range c {
|
|
res[i] = fmt.Sprintf("%v", x.Value)
|
|
}
|
|
|
|
return strings.Join(res, ", ")
|
|
}
|
|
|
|
// SetChoices sets the given [Contexter]'s choices.
|
|
func SetChoices[T comparable](c Contexter, choices ValueChoices[T]) {
|
|
c.SetContext(context.WithValue(c.Context(), ctxChoicesKey, choices))
|
|
}
|
|
|
|
// GetChoices returns the given [Contexter]'s choices.
|
|
func GetChoices[T comparable](c Contexter) ValueChoices[T] {
|
|
v, _ := c.Context().Value(ctxChoicesKey).(ValueChoices[T])
|
|
return v
|
|
}
|
|
|
|
// Choices is a [FieldOption] that adds a choice list to the field.
|
|
func Choices[T comparable](choices ...ValueChoice[T]) FieldOption {
|
|
return func(field Field) {
|
|
// Set choices to the field
|
|
SetChoices(field, ValueChoices[T](choices))
|
|
|
|
// Choices validator
|
|
SetValidators(field, append(GetValidators(field), ValueValidatorFunc[T](func(f Field, v T) error {
|
|
if f.IsNil() {
|
|
return nil
|
|
}
|
|
|
|
choices := GetChoices[T](field)
|
|
errs := []error{}
|
|
for _, choice := range choices {
|
|
if choice.Value == v {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
errs = append(errs, Gettext("%v is not one of %s", v, choices))
|
|
return errors.Join(errs...)
|
|
}))...)
|
|
}
|
|
}
|
|
|
|
// ChoicesPairs returns a [Choices] based on pairs of key/value. Strings only.
|
|
func ChoicesPairs(items [][2]string) FieldOption {
|
|
choices := make([]ValueChoice[string], len(items))
|
|
for i, item := range items {
|
|
choices[i] = Choice(item[1], item[0])
|
|
}
|
|
|
|
return Choices(choices...)
|
|
}
|
|
|
|
// Choice returns a new [ValueChoice] instance.
|
|
func Choice[T comparable](name string, value T) ValueChoice[T] {
|
|
return ValueChoice[T]{Name: name, Value: value}
|
|
}
|