Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add suport of validate struct tags in struct-tag rule #1244

Merged
merged 3 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
211 changes: 211 additions & 0 deletions rule/struct_tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const (
keyProtobuf = "protobuf"
keyRequired = "required"
keyURL = "url"
keyValidate = "validate"
keyXML = "xml"
keyYAML = "yaml"
)
Expand Down Expand Up @@ -216,6 +217,12 @@ func (w lintStructTagRule) checkTaggedField(f *ast.Field) {
if !ok {
w.addFailure(f.Tag, msg)
}
case keyValidate:
opts := append([]string{tag.Name}, tag.Options...)
msg, ok := w.checkValidateTag(opts)
if !ok {
w.addFailure(f.Tag, msg)
}
case keyXML:
msg, ok := w.checkXMLTag(tag.Options)
if !ok {
Expand Down Expand Up @@ -400,6 +407,59 @@ func (w lintStructTagRule) checkMapstructureTag(options []string) (string, bool)
return "", true
}

func (w lintStructTagRule) checkValidateTag(options []string) (string, bool) {
previousOption := ""
seenKeysOption := false
for _, opt := range options {
switch opt {
case "keys":
if previousOption != "dive" {
return "option 'keys' must follow a 'dive' option in validate tag", false
}
seenKeysOption = true
case "endkeys":
if !seenKeysOption {
return "option 'endkeys' without a previous 'keys' option in validate tag", false
}
seenKeysOption = false
default:
parts := strings.Split(opt, "|")
errMsg, ok := w.checkValidateOptionsAlternatives(parts)
if !ok {
return errMsg, false
}
}
previousOption = opt
}

return "", true
}

func (w lintStructTagRule) checkValidateOptionsAlternatives(alternatives []string) (string, bool) {
for _, alternative := range alternatives {
alternative := strings.TrimSpace(alternative)
parts := strings.Split(alternative, "=")
switch len(parts) {
case 1:
badOpt, ok := areValidateOpts(parts[0])
if ok || w.isUserDefined(keyValidate, badOpt) {
continue
}
return fmt.Sprintf("unknown option '%s' in validate tag", badOpt), false
case 2:
lhs := parts[0]
_, ok := validateLHS[lhs]
if ok || w.isUserDefined(keyValidate, lhs) {
continue
}
return fmt.Sprintf("unknown option '%s' in validate tag", lhs), false
default:
return fmt.Sprintf("malformed options '%s' in validate tag, not expected more than one '='", alternative), false
}
}
return "", true
}

func (lintStructTagRule) typeValueMatch(t ast.Expr, val string) bool {
tID, ok := t.(*ast.Ident)
if !ok {
Expand Down Expand Up @@ -500,3 +560,154 @@ func (w lintStructTagRule) isUserDefined(key, opt string) bool {
}
return false
}

func areValidateOpts(opts string) (string, bool) {
parts := strings.Split(opts, "|")
for _, opt := range parts {
_, ok := validateSingleOptions[opt]
if !ok {
return opt, false
}
}

return "", true
}

var validateSingleOptions = map[string]struct{}{
"alpha": {},
"alphanum": {},
"alphanumunicode": {},
"alphaunicode": {},
"ascii": {},
"base32": {},
"base64": {},
"base64url": {},
"bcp47_language_tag": {},
"boolean": {},
"bic": {},
"btc_addr": {},
"btc_addr_bech32": {},
"cidr": {},
"cidrv4": {},
"cidrv6": {},
"country_code": {},
"credit_card": {},
"cron": {},
"cve": {},
"datauri": {},
"dir": {},
"dirpath": {},
"dive": {},
"dns_rfc1035_label": {},
"e164": {},
"email": {},
"eth_addr": {},
"file": {},
"filepath": {},
"fqdn": {},
"hexadecimal": {},
"hexcolor": {},
"hostname": {},
"hostname_port": {},
"hostname_rfc1123": {},
"hsl": {},
"hsla": {},
"html": {},
"html_encoded": {},
"image": {},
"ip": {},
"ip4_addr": {},
"ip6_addr": {},
"ip_addr": {},
"ipv4": {},
"ipv6": {},
"isbn": {},
"isbn10": {},
"isbn13": {},
"isdefault": {},
"iso3166_1_alpha2": {},
"iso3166_1_alpha3": {},
"iscolor": {},
"json": {},
"jwt": {},
"latitude": {},
"longitude": {},
"lowercase": {},
"luhn_checksum": {},
"mac": {},
"mongodb": {},
"mongodb_connection_string": {},
"multibyte": {},
"nostructlevel": {},
"number": {},
"numeric": {},
"omitempty": {},
"printascii": {},
"required": {},
"rgb": {},
"rgba": {},
"semver": {},
"ssn": {},
"structonly": {},
"tcp_addr": {},
"tcp4_addr": {},
"tcp6_addr": {},
"timezone": {},
"udp4_addr": {},
"udp6_addr": {},
"ulid": {},
"unique": {},
"unix_addr": {},
"uppercase": {},
"uri": {},
"url": {},
"url_encoded": {},
"urn_rfc2141": {},
"uuid": {},
"uuid3": {},
"uuid4": {},
"uuid5": {},
}

var validateLHS = map[string]struct{}{
"contains": {},
"containsany": {},
"containsfield": {},
"containsrune": {},
"datetime": {},
"endsnotwith": {},
"endswith": {},
"eq": {},
"eqfield": {},
"eqcsfield": {},
"excluded_if": {},
"excluded_unless": {},
"excludes": {},
"excludesall": {},
"excludesfield": {},
"excludesrune": {},
"gt": {},
"gtcsfield": {},
"gtecsfield": {},
"len": {},
"lt": {},
"lte": {},
"ltcsfield": {},
"ltecsfield": {},
"max": {},
"min": {},
"ne": {},
"necsfield": {},
"oneof": {},
"oneofci": {},
"required_if": {},
"required_unless": {},
"required_with": {},
"required_with_all": {},
"required_without": {},
"required_without_all": {},
"spicedb": {},
"startsnotwith": {},
"startswith": {},
"unique": {},
}
1 change: 1 addition & 0 deletions test/struct_tag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func TestStructTagWithUserOptions(t *testing.T) {
"url,myURLOption",
"datastore,myDatastoreOption",
"mapstructure,myMapstructureOption",
"validate,displayName",
},
})
}
Expand Down
12 changes: 12 additions & 0 deletions testdata/struct_tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,15 @@ type MapStruct struct {
Field1 string `mapstructure:",squash,reminder,omitempty"`
OtherField string `mapstructure:",unknownOption"` // MATCH /unknown option 'unknownOption' in Mapstructure tag/
}

type ValidateUser struct {
Username string `validate:"required,min=3,max=32"`
Email string `validate:"required,email"`
Password string `validate:"required,min=8,max=32"`
Biography string `validate:"min=0,max=1000"`
DisplayName string `validate:"displayName,min=3,max=32"` // MATCH /unknown option 'displayName' in validate tag/
Complex string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,required"`
BadComplex string `validate:"gt=0,keys,eq=1|eq=2,endkeys,required"` // MATCH /option 'keys' must follow a 'dive' option in validate tag/
BadComplex2 string `validate:"gt=0,dive,eq=1|eq=2,endkeys,required"` // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
BadComplex3 string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,endkeys,required"` // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
}
12 changes: 12 additions & 0 deletions testdata/struct_tag_user_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,15 @@ type MapStruct struct {
Field1 string `mapstructure:",squash,reminder,omitempty,myMapstructureOption"`
OtherField string `mapstructure:",unknownOption"` // MATCH /unknown option 'unknownOption' in Mapstructure tag/
}

type ValidateUser struct {
Username string `validate:"required,min=3,max=32"`
Email string `validate:"required,email"`
Password string `validate:"required,min=8,max=32"`
Biography string `validate:"min=0,max=1000"`
DisplayName string `validate:"displayName,min=3,max=32"`
Complex string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,required"`
BadComplex string `validate:"gt=0,keys,eq=1|eq=2,endkeys,required"` // MATCH /option 'keys' must follow a 'dive' option in validate tag/
BadComplex2 string `validate:"gt=0,dive,eq=1|eq=2,endkeys,required"` // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
BadComplex3 string `validate:"gt=0,dive,keys,eq=1|eq=2,endkeys,endkeys,required"` // MATCH /option 'endkeys' without a previous 'keys' option in validate tag/
}