Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"Elts",
"Fset",
"Messagef",
"RRGGBB",
"RRGGBBAA",
"Skłodowska",
"atoi",
"cidrv",
Expand Down Expand Up @@ -70,4 +72,4 @@
"Implicits",
"mygovy"
]
}
}
46 changes: 26 additions & 20 deletions internal/messagetemplates/templatekey_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 27 additions & 1 deletion internal/messagetemplates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ const (
StringCIDRv4Template
StringCIDRv6Template
StringJSONTemplate
StringHexColorTemplate
StringRGBTemplate
StringRGBATemplate
StringHSLTemplate
StringHSLATemplate
StringCMYKTemplate
StringContainsTemplate
StringExcludesTemplate
StringStartsWithTemplate
Expand Down Expand Up @@ -122,6 +128,12 @@ var rawMessageTemplates = map[templateKey]string{
StringCIDRv4Template: "string must be a valid CIDR notation IPv4 address",
StringCIDRv6Template: "string must be a valid CIDR notation IPv6 address",
StringJSONTemplate: "string must be a valid JSON",
StringHexColorTemplate: "string must be a valid CSS hex color",
StringRGBTemplate: "string must be a valid legacy comma-separated rgb(...) color",
StringRGBATemplate: "string must be a valid legacy comma-separated rgba(...) color",
StringHSLTemplate: "string must be a valid legacy comma-separated hsl(...) color",
StringHSLATemplate: "string must be a valid legacy comma-separated hsla(...) color",
StringCMYKTemplate: "string must be a valid comma-separated cmyk(...) color",
StringContainsTemplate: `string must contain the following substrings: {{ joinSlice .ComparisonValue "'" }}`,
StringExcludesTemplate: `string must not contain any of the following substrings: {{ joinSlice .ComparisonValue "'" }}`,
StringStartsWithTemplate: `
Expand Down Expand Up @@ -187,7 +199,21 @@ var rawMessageTemplates = map[templateKey]string{
{{- if gt (len .Custom.Constraints) 0 }} based on constraints: {{ joinSlice .Custom.Constraints "" }}{{- end }}`,
UniquePropertiesTemplate: `all of [{{ joinSlice .ComparisonValue "" }}] properties must be unique, but '{{ .Custom.FirstProperty }}' collides with '{{ .Custom.SecondProperty }}'
{{- if gt (len .Custom.Constraints) 0 }}, based on constraints: {{ joinSlice .Custom.Constraints "" }}{{- end }}`,
URLTemplate: "{{ .Error }}",
URLTemplate: `
{{- if .Error -}}
{{ .Error }}
{{- else if .Custom.Scheme -}}
valid URL must use one of the following schemes: {{ joinSlice .ComparisonValue "'" }}
{{- else if .Custom.HostRequired -}}
valid URL must have a host
{{- else if .Custom.UserInfoForbidden -}}
valid URL must not contain user information
{{- else if .Custom.HostDenyList -}}
valid URL must not use any of the following hostnames: {{ joinSlice .ComparisonValue "'" }}
{{- else if .Custom.HostAllowList -}}
valid URL must use one of the following hostnames: {{ joinSlice .ComparisonValue "'" }}
{{- end }}
`,
}

// templateDependencies defines dependency templates for a given template.
Expand Down
6 changes: 6 additions & 0 deletions pkg/rules/error_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const (
ErrorCodeStringUUID govy.ErrorCode = "string_uuid"
ErrorCodeStringEmail govy.ErrorCode = "string_email"
ErrorCodeStringJSON govy.ErrorCode = "string_json"
ErrorCodeStringHexColor govy.ErrorCode = "string_hex_color"
ErrorCodeStringRGB govy.ErrorCode = "string_rgb"
ErrorCodeStringRGBA govy.ErrorCode = "string_rgba"
ErrorCodeStringHSL govy.ErrorCode = "string_hsl"
ErrorCodeStringHSLA govy.ErrorCode = "string_hsla"
ErrorCodeStringCMYK govy.ErrorCode = "string_cmyk"
ErrorCodeStringContains govy.ErrorCode = "string_contains"
ErrorCodeStringExcludes govy.ErrorCode = "string_excludes"
ErrorCodeStringStartsWith govy.ErrorCode = "string_starts_with"
Expand Down
30 changes: 30 additions & 0 deletions pkg/rules/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package rules_test

import (
"fmt"
"net/url"
"time"

"github.com/nobl9/govy/pkg/govy"
Expand All @@ -18,6 +19,35 @@ type Student struct {
IndexCopy string `json:"indexCopy,omitempty"`
}

func ExampleURL_withOptions() {
rule := rules.URL(
rules.URLHostRequired(),
rules.URLSchemes("https"),
rules.URLUserInfoForbidden(),
rules.URLHostAllowList("api.example.com"),
)
mustParseURL := func(rawURL string) *url.URL {
parsedURL, err := url.Parse(rawURL)
if err != nil {
panic(err)
}
return parsedURL
}

fmt.Println(rule.Validate(mustParseURL("https://api.example.com/v1")) == nil)
fmt.Println(rule.Validate(mustParseURL("http://api.example.com/v1")))
fmt.Println(rule.Validate(mustParseURL("mailto:alerts@example.com")))
fmt.Println(rule.Validate(mustParseURL("https://user@api.example.com/v1")))
fmt.Println(rule.Validate(mustParseURL("https://billing.example.com/v1")))

// Output:
// true
// valid URL must use one of the following schemes: 'https'
// valid URL must have a host
// valid URL must not contain user information
// valid URL must use one of the following hostnames: 'api.example.com'
}

func ExampleSliceUnique() {
v := govy.New(
govy.ForSlice(func(t Teacher) []Student { return t.Students }).
Expand Down
5 changes: 3 additions & 2 deletions pkg/rules/regex.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import (
// Define all regular expressions here:
var (
// Ref: https://www.ietf.org/rfc/rfc4122.txt
uuidRegexp = lazyRegexCompile(`^[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}$`)
asciiRegexp = lazyRegexCompile(`^[\x00-\x7F]*$`)
uuidRegexp = lazyRegexCompile(`^[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}$`)
asciiRegexp = lazyRegexCompile(`^[\x00-\x7F]*$`)
hexColorRegexp = lazyRegexCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
// Ref: https://www.ietf.org/rfc/rfc1123.txt
rfc1123DnsLabelRegexp = lazyRegexCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
rfc1123DnsSubdomainRegexp = lazyRegexCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
Expand Down
5 changes: 5 additions & 0 deletions pkg/rules/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ func TestRules_EnsureTestsAndBenchmarksAreWritten(t *testing.T) {
"HashFuncSelf": true,
"CompareFunc": true,
"CompareDeepEqualFunc": true,
"URLSchemes": true,
"URLHostRequired": true,
"URLUserInfoForbidden": true,
"URLHostAllowList": true,
"URLHostDenyList": true,
}
rulesDir := filepath.Join(internal.FindModuleRoot(), "pkg/rules")
fset := token.NewFileSet()
Expand Down
182 changes: 182 additions & 0 deletions pkg/rules/string_format_color.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package rules

import (
"strconv"
"strings"

"github.com/nobl9/govy/internal/messagetemplates"
"github.com/nobl9/govy/pkg/govy"
)

// StringHexColor ensures the property's value is a CSS hexadecimal color.
// It accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA forms.
func StringHexColor() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringHexColorTemplate)
return govy.NewRule(func(s string) error {
if !hexColorRegexp().MatchString(s) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{PropertyValue: s})
}
return nil
}).
WithErrorCode(ErrorCodeStringHexColor).
WithMessageTemplate(tpl).
WithDescription("string must be a CSS hex color in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format")
}

// StringRGB ensures the property's value is a legacy comma-separated rgb(...) color.
// It accepts either three numeric 0-255 components or three percentage 0-100% components.
func StringRGB() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringRGBTemplate)
return govy.NewRule(func(s string) error {
args, ok := splitCSSColorFunctionArgs(s, "rgb", 3)
if !ok || !validRGBComponents(args) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{PropertyValue: s})
}
return nil
}).
WithErrorCode(ErrorCodeStringRGB).
WithMessageTemplate(tpl).
WithDescription("string must be a legacy comma-separated rgb(...) color with numeric 0-255 or percentage 0-100% components")
}

// StringRGBA ensures the property's value is a legacy comma-separated rgba(...) color.
// It accepts RGB components and an alpha component of 0, 1, or a fractional 0.x value.
func StringRGBA() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringRGBATemplate)
return govy.NewRule(func(s string) error {
args, ok := splitCSSColorFunctionArgs(s, "rgba", 4)
if !ok || !validRGBComponents(args[:3]) || !validAlphaComponent(args[3]) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{PropertyValue: s})
}
return nil
}).
WithErrorCode(ErrorCodeStringRGBA).
WithMessageTemplate(tpl).
WithDescription("string must be a legacy comma-separated rgba(...) color with numeric 0-255 or percentage 0-100% RGB components and alpha 0, 1, or 0.x")
}

// StringHSL ensures the property's value is a legacy comma-separated hsl(...) color.
// It accepts a hue from 0 to 360 and saturation/lightness percentages from 0% to 100%.
func StringHSL() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringHSLTemplate)
return govy.NewRule(func(s string) error {
args, ok := splitCSSColorFunctionArgs(s, "hsl", 3)
if !ok || !validHSLComponents(args) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{PropertyValue: s})
}
return nil
}).
WithErrorCode(ErrorCodeStringHSL).
WithMessageTemplate(tpl).
WithDescription("string must be a legacy comma-separated hsl(...) color with hue 0-360 and saturation/lightness 0-100%")
}

// StringHSLA ensures the property's value is a legacy comma-separated hsla(...) color.
// It accepts HSL components and an alpha component of 0, 1, or a fractional 0.x value.
func StringHSLA() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringHSLATemplate)
return govy.NewRule(func(s string) error {
args, ok := splitCSSColorFunctionArgs(s, "hsla", 4)
if !ok || !validHSLComponents(args[:3]) || !validAlphaComponent(args[3]) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{PropertyValue: s})
}
return nil
}).
WithErrorCode(ErrorCodeStringHSLA).
WithMessageTemplate(tpl).
WithDescription("string must be a legacy comma-separated hsla(...) color with hue 0-360, saturation/lightness 0-100%, and alpha 0, 1, or 0.x")
}

// StringCMYK ensures the property's value is a cmyk(...) color.
// It accepts four percentage components from 0% to 100%.
func StringCMYK() govy.Rule[string] {
tpl := messagetemplates.Get(messagetemplates.StringCMYKTemplate)
return govy.NewRule(func(s string) error {
args, ok := splitCSSColorFunctionArgs(s, "cmyk", 4)
if !ok || !allCSSColorComponents(args, validPercentComponent) {
return govy.NewRuleErrorTemplate(govy.TemplateVars{PropertyValue: s})
}
return nil
}).
WithErrorCode(ErrorCodeStringCMYK).
WithMessageTemplate(tpl).
WithDescription("string must be a cmyk(...) color with four percentage components from 0% to 100%")
}

func splitCSSColorFunctionArgs(s, name string, count int) ([]string, bool) {
prefix := name + "("
if !strings.HasPrefix(s, prefix) || !strings.HasSuffix(s, ")") {
return nil, false
}
body := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(s, prefix), ")"))
parts := strings.Split(body, ",")
if len(parts) != count {
return nil, false
}
args := make([]string, 0, count)
for _, part := range parts {
arg := strings.TrimSpace(part)
if arg == "" {
return nil, false
}
args = append(args, arg)
}
return args, true
}

func validRGBComponents(args []string) bool {
return allCSSColorComponents(args, validRGBNumericComponent) ||
allCSSColorComponents(args, validPercentComponent)
}

func validHSLComponents(args []string) bool {
return validIntComponent(args[0], 0, 360) &&
validPercentComponent(args[1]) &&
validPercentComponent(args[2])
}

func allCSSColorComponents(args []string, valid func(string) bool) bool {
for _, arg := range args {
if !valid(arg) {
return false
}
}
return true
}

func validRGBNumericComponent(s string) bool {
return validIntComponent(s, 0, 255)
}

func validPercentComponent(s string) bool {
value, ok := strings.CutSuffix(s, "%")
return ok && validIntComponent(value, 0, 100)
}

func validAlphaComponent(s string) bool {
if s == "0" || s == "1" {
return true
}
value, ok := strings.CutPrefix(s, "0.")
return ok && isASCIIDigits(value)
}

func validIntComponent(s string, minValue, maxValue int) bool {
if !isASCIIDigits(s) {
return false
}
n, err := strconv.Atoi(s)
return err == nil && n >= minValue && n <= maxValue
}

func isASCIIDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
Loading
Loading