diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index d2fe926..506699c 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -10,4 +10,4 @@ jobs: - uses: Slashgear/action-check-pr-title@v4.3.0 with: regexp: "^(feat|fix|sec|infra|test|chore|doc): .{5,}" - helpMessage: "Example: 'feat: new pr title check BE-143' <- prefix, colon, space, PR title of at least 5 chars (with ticket number strongly suggested, but not mandatory)" \ No newline at end of file + helpMessage: "Example: 'feat: new pr title' <- prefix, colon, space, PR title of at least 5 chars" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 9c56f6c..84c57bc 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,7 +4,7 @@ on: push: branches: - main - # pull_request event is required only for auto-labeler + # pull_request event is required only for autolabeler # 'edited' event is required to account for initial invalid PR names pull_request: types: [opened, reopened, synchronize, edited] @@ -17,7 +17,7 @@ jobs: permissions: # write permission is required to create a github release contents: write - # write permission is required for auto-labeler + # write permission is required for autolabeler # otherwise, read permission is required at least pull-requests: write runs-on: ubuntu-latest @@ -34,4 +34,4 @@ jobs: uses: release-drafter/release-drafter@v6 - name: Add release notes to the draft if: github.event_name == 'push' - run: .github/scripts/release-notes.sh ${{ steps.drafter.outputs.tag_name }} \ No newline at end of file + run: .github/scripts/release-notes.sh ${{ steps.drafter.outputs.tag_name }} diff --git a/pkg/govy/errors.go b/pkg/govy/errors.go index fb04901..f0c111e 100644 --- a/pkg/govy/errors.go +++ b/pkg/govy/errors.go @@ -221,6 +221,31 @@ func (r RuleSetError) Error() string { return b.String() } +// NewRuleErrorTemplate creates a new [RuleErrorTemplate] with the given template variables. +// The variables can be of any type, most commonly it would be a struct or a map. +// These variables are then passed to [template.Template.Execute]. +// For more details on Go templates see: https://pkg.go.dev/text/template. +// Example: +// +// return govy.NewRuleErrorTemplate(map[string]string{ +// "Name": "my-property", +// "MaxLength": 2, +// }) +func NewRuleErrorTemplate(vars any) RuleErrorTemplate { + return RuleErrorTemplate{Vars: vars} +} + +// RuleErrorTemplate is a container for passing template variables under the guise of an error. +// It's not meant to be used directly as an error but rather +// unpacked by [Rule] in order to create a templated error message. +type RuleErrorTemplate struct { + Vars any +} + +func (e RuleErrorTemplate) Error() string { + return fmt.Sprintf("%T should not be used directly", e) +} + // HasErrorCode checks if an error contains given [ErrorCode]. // It supports all govy errors. func HasErrorCode(err error, code ErrorCode) bool { diff --git a/pkg/govy/rule.go b/pkg/govy/rule.go index 59b827b..8326b0d 100644 --- a/pkg/govy/rule.go +++ b/pkg/govy/rule.go @@ -2,6 +2,7 @@ package govy import ( "fmt" + "text/template" ) // NewRule creates a new [Rule] instance. @@ -13,19 +14,23 @@ func NewRule[T any](validate func(v T) error) Rule[T] { // It evaluates the provided validation function and enhances it // with optional [ErrorCode] and arbitrary details. type Rule[T any] struct { - validate func(v T) error - errorCode ErrorCode - details string - message string - description string + validate func(v T) error + errorCode ErrorCode + details string + message string + messageTemplate *template.Template + description string } // Validate runs validation function on the provided value. // It can handle different types of errors returned by the function: -// - *[RuleError], which details and [ErrorCode] are optionally extended with the ones defined by [Rule]. -// - *[PropertyError], for each of its errors their [ErrorCode] is extended with the one defined by [Rule]. +// - [*RuleError], which details and [ErrorCode] are optionally extended with the ones defined by [Rule]. +// - [*PropertyError], for each of its errors their [ErrorCode] is extended with the one defined by [Rule]. +// - [*RuleErrorTemplate], if message template was set with [Rule.WithMessageTemplate] or +// [Rule.WithMessageTemplateString] then the [RuleError.Message] is constructed from the provided template +// using variables passed inside [RuleErrorTemplate.Vars]. // -// By default, it will construct a new RuleError. +// By default, it will construct a new [*RuleError]. func (r Rule[T]) Validate(v T) error { if err := r.validate(v); err != nil { switch ev := err.(type) { @@ -41,6 +46,7 @@ func (r Rule[T]) Validate(v T) error { _ = e.AddCode(r.errorCode) } return ev + case *RuleErrorTemplate: default: msg := ev.Error() if len(r.message) > 0 { @@ -63,8 +69,9 @@ func (r Rule[T]) WithErrorCode(code ErrorCode) Rule[T] { return r } -// WithMessage overrides the returned [RuleError] error message with message. +// WithMessage overrides the returned [RuleError] error message. func (r Rule[T]) WithMessage(format string, a ...any) Rule[T] { + r.messageTemplate = nil if len(a) == 0 { r.message = format } else { @@ -73,6 +80,19 @@ func (r Rule[T]) WithMessage(format string, a ...any) Rule[T] { return r } +// WithMessageTemplate overrides the returned [RuleError] error message using provided [template.Template]. +func (r Rule[T]) WithMessageTemplate(tpl *template.Template) Rule[T] { + r.messageTemplate = tpl + return r +} + +// WithMessageTemplateString overrides the returned [RuleError] error message using provided template string. +// The string is parsed into [template.Template], it panics if any error is encountered during parsing. +func (r Rule[T]) WithMessageTemplateString(tplStr string) Rule[T] { + tpl := template.Must(template.New("").Parse(tplStr)) + return r.WithMessageTemplate(tpl) +} + // WithDetails adds details to the returned [RuleError] error message. func (r Rule[T]) WithDetails(format string, a ...any) Rule[T] { if len(a) == 0 { @@ -90,6 +110,9 @@ func (r Rule[T]) WithDescription(description string) Rule[T] { return r } +func (r Rule[T]) getErrorMessage(err error) string { +} + func (r Rule[T]) plan(builder planBuilder) { builder.rulePlan = RulePlan{ ErrorCode: r.errorCode, diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 6d5c39a..0ecd487 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -86,7 +86,7 @@ func (emptyErr) Error() string { return "" } // PropertyRules is responsible for validating a single property. // It is a collection of rules, predicates, and other properties that define how the property should be validated. -// IT is the middle-level building block of the validation process, +// It is the middle-level building block of the validation process, // aggregated by [Validator] and aggregating [Rule]. type PropertyRules[T, S any] struct { name string diff --git a/pkg/rules/length.go b/pkg/rules/length.go index 8b02948..4d40642 100644 --- a/pkg/rules/length.go +++ b/pkg/rules/length.go @@ -9,25 +9,26 @@ import ( ) // StringLength ensures the string's length is between min and max (closed interval). -func StringLength(lower, upper int) govy.Rule[string] { - msg := "length must be between {{ .MinLength }} and {{ .MaxLength }}" - tpl := getMessageTemplate("StringLength", msg) +func StringLength(minLen, maxLen int) govy.Rule[string] { + tpl := getMessageTemplate(stringLengthTemplateKey) return govy.NewRule(func(v string) error { length := utf8.RuneCountInString(v) - if length < lower || length > upper { - return returnTemplatedError(tpl, func() templateVariables[string] { - return templateVariables[string]{ - PropertyValue: v, - MinLength: lower, - MaxLength: upper, - } + if length < minLen || length > maxLen { + return govy.NewRuleErrorTemplate(ruleErrorTemplateVars[string]{ + PropertyValue: v, + MinLength: minLen, + MaxLength: maxLen, }) } return nil }). WithErrorCode(ErrorCodeStringLength). - WithDescription(msg) + WithMessageTemplate(tpl). + WithDescription(mustExecuteTemplate(tpl, ruleErrorTemplateVars[string]{ + MinLength: minLen, + MaxLength: maxLen, + })) } // StringMinLength ensures the string's length is greater than or equal to the limit. diff --git a/pkg/rules/message_templates.go b/pkg/rules/message_templates.go index 44dfddd..b45fdc1 100644 --- a/pkg/rules/message_templates.go +++ b/pkg/rules/message_templates.go @@ -2,55 +2,71 @@ package rules import ( "bytes" - "errors" + "fmt" "log/slog" "sync" "text/template" ) -type templateVariables[T any] struct { +type templateKey int + +const ( + stringLengthTemplateKey templateKey = iota + 1 +) + +var rawMessageTemplates = map[templateKey]string{ + stringLengthTemplateKey: "length must be between {{ .MinLength }} and {{ .MaxLength }}", +} + +// ruleErrorTemplateVars lists all the possible variables that can be used builtin rules' message templates. +type ruleErrorTemplateVars[T any] struct { PropertyValue T + Error string MinLength int MaxLength int } -func returnTemplatedError[T any](tpl *template.Template, getVars func() templateVariables[T]) error { +func mustExecuteTemplate[T any](tpl *template.Template, vars ruleErrorTemplateVars[T]) string { var buf bytes.Buffer - if err := tpl.Execute(&buf, getVars()); err != nil { + if err := tpl.Execute(&buf, vars); err != nil { slog.Error("failed to execute message template", slog.String("template", tpl.Name()), slog.String("error", err.Error())) } - return errors.New(buf.String()) + return buf.String() } -var messageTemplates = messageTemplatesMap{ - tmpl: make(map[string]*template.Template), +var messageTemplatesCache = messageTemplatesMap{ + tmpl: make(map[templateKey]*template.Template), mu: sync.RWMutex{}, } type messageTemplatesMap struct { - tmpl map[string]*template.Template + tmpl map[templateKey]*template.Template mu sync.RWMutex } -func (p *messageTemplatesMap) Lookup(name string) *template.Template { +func (p *messageTemplatesMap) Lookup(key templateKey) *template.Template { p.mu.RLock() defer p.mu.RUnlock() - return p.tmpl[name] + return p.tmpl[key] } -func (p *messageTemplatesMap) Register(name string, tpl *template.Template) { +func (p *messageTemplatesMap) Register(key templateKey, tpl *template.Template) { p.mu.Lock() - p.tmpl[name] = tpl + p.tmpl[key] = tpl p.mu.Unlock() } -func getMessageTemplate(name, msg string) *template.Template { - if tpl := messageTemplates.Lookup(name); tpl != nil { +func getMessageTemplate(key templateKey) *template.Template { + if tpl := messageTemplatesCache.Lookup(key); tpl != nil { return tpl } - tpl := template.Must(template.New(name).Parse(msg)) - messageTemplates.Register(name, tpl) + text, ok := rawMessageTemplates[key] + if !ok { + panic(fmt.Sprintf("message template %q was not found", key)) + } + tpl := template.Must(template.New("").Parse(text)) + messageTemplatesCache.Register(key, tpl) return tpl }