diff --git a/docs/validation.md b/docs/validation.md index 325a5a65..b58bbf95 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -19,7 +19,7 @@ When `true`, no `Validate()` methods are generated. Use this if you don't need v ### `simple` -When `true`, all struct types use simple `validate.Struct()` validation instead of custom validation logic. This produces cleaner code but doesn't support advanced features like union type validation. +When `true`, all struct types use simple `validate.Struct()` validation instead of custom validation logic. This produces cleaner code but doesn't support advanced features like union type validation or regex `pattern` constraints. ### `response` @@ -42,6 +42,11 @@ The following OpenAPI constraints are translated to validation tags: | `minItems` | `min=N` | arrays | | `maxItems` | `max=N` | arrays | | `enum` | custom switch | string, integer enums | +| `pattern` | `runtime.ValidatePattern()` | strings | + +`pattern` is a regex, which `validate.Struct()` cannot express, so it is enforced +wherever per-field/per-item validation is generated (full-mode structs, arrays, maps). +It is not enforced in `simple` mode (see below). ## Generated Code Examples diff --git a/examples/circular/mutual/gen.go b/examples/circular/mutual/gen.go index fa223b3f..1066e1f1 100644 --- a/examples/circular/mutual/gen.go +++ b/examples/circular/mutual/gen.go @@ -282,6 +282,9 @@ func (f File_Links) Validate() error { if err := typesValidator.Var(f.URL, "required,max=5000"); err != nil { errors = errors.Append("URL", err) } + if err := runtime.ValidatePattern(f.URL, `^/v1/file_links`); err != nil { + errors = errors.Append("URL", err) + } if len(errors) == 0 { return nil } diff --git a/examples/client/example1/example1/headers.go b/examples/client/example1/example1/headers.go index 4112216c..fff9ef08 100644 --- a/examples/client/example1/example1/headers.go +++ b/examples/client/example1/example1/headers.go @@ -13,7 +13,17 @@ type GetClientHeaders struct { } func (g GetClientHeaders) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(g)) + var errors runtime.ValidationErrors + if err := typesValidator.Var(g.MerchantSerialNumber, "required,max=7,min=4"); err != nil { + errors = errors.Append("MerchantSerialNumber", err) + } + if err := runtime.ValidatePattern(g.MerchantSerialNumber, `^[0-9]{4,7}$`); err != nil { + errors = errors.Append("MerchantSerialNumber", err) + } + if len(errors) == 0 { + return nil + } + return errors } type UpdateClientHeaders struct { @@ -21,5 +31,15 @@ type UpdateClientHeaders struct { } func (u UpdateClientHeaders) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(u)) + var errors runtime.ValidationErrors + if err := typesValidator.Var(u.MerchantSerialNumber, "required,max=7,min=4"); err != nil { + errors = errors.Append("MerchantSerialNumber", err) + } + if err := runtime.ValidatePattern(u.MerchantSerialNumber, `^[0-9]{4,7}$`); err != nil { + errors = errors.Append("MerchantSerialNumber", err) + } + if len(errors) == 0 { + return nil + } + return errors } diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 363785ae..b011d25e 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -145,6 +145,11 @@ func (s GoSchema) NeedsValidation() bool { return true } + // If it carries a regex pattern, it needs validation + if s.needsPatternValidation() { + return true + } + // If it has union elements, it needs validation if len(s.UnionElements) > 0 { return true @@ -157,6 +162,10 @@ func (s GoSchema) NeedsValidation() bool { if len(prop.Constraints.ValidationTags) > 0 { return true } + // Property carries a regex pattern + if prop.needsPatternValidation() { + return true + } // Property needs custom validation (RefType, struct, union, etc.) if prop.needsCustomValidation() { return true @@ -216,6 +225,68 @@ func (s GoSchema) NeedsValidation() bool { return true } +// ValidateDecl generates the body of the Validate() method for this schema. +// It returns the Go code that should appear inside the Validate() method. +// The alias parameter is the receiver variable name (e.g., "p" for "func (p Person) Validate()"). +// The validatorVar parameter is the name of the validator variable to use (e.g., "bodyTypesValidate"). +func (s GoSchema) ValidateDecl(alias string, validatorVar string) string { + return s.ValidateDeclWithOptions(alias, validatorVar, false) +} + +// ValidateDeclWithOptions generates the body of the Validate() method for this schema with options. +// The forceSimple parameter forces the use of simple validation (validate.Struct()) even for complex types. +func (s GoSchema) ValidateDeclWithOptions(alias string, validatorVar string, forceSimple bool) string { + // If forceSimple is true, always use simple validation for structs + if forceSimple && isStructType(s) { + return generateSimpleStructValidation(s, alias, validatorVar) + } + + // OPTIMIZATION: If this is a struct with no unions anywhere in its tree, + // AND no properties need custom validation (like RefTypes), + // we can use the simple validate.Struct() approach instead of custom validation. + // This is much cleaner and more efficient. + if canUseSimpleStructValidation(s) { + return generateSimpleStructValidation(s, alias, validatorVar) + } + + // Handle array types + if isArrayType(s) { + return generateArrayValidation(s, alias, validatorVar) + } + + // If this schema has a RefType set, it means it's a reference to another type + // In this case, we should delegate validation to the underlying type + if isRefTypeDelegation(s) { + return generateRefTypeDelegation(s, alias) + } + + // If this schema has properties but GoType is a reference to another type + // (not a struct/map/slice), delegate to the underlying type + if isTypeAliasDelegation(s) { + return generateTypeAliasDelegation(s, alias) + } + + // Handle map types (from additionalProperties) + if isMapType(s) && !hasCustomValidation(s) { + return generateMapValidation(s, alias, validatorVar) + } + + // For other non-struct types (slices, primitives) without custom validation + if !hasCustomValidation(s) { + return generateNonStructValidation(s, alias, validatorVar) + } + + // Generate custom validation for struct properties + return generateCustomPropertyValidation(s, alias, validatorVar) +} + +// needsPatternValidation reports whether this schema carries an enforceable regex pattern. +func (s GoSchema) needsPatternValidation() bool { + return s.Constraints.hasPattern() && + isPatternValidatable(s.TypeDecl()) && + patternCompiles(*s.Constraints.Pattern) +} + // ContainsUnions returns true if this schema or any of its nested schemas contain union types. // This is used to determine if we can use simple validate.Struct() or need custom validation logic. func (s GoSchema) ContainsUnions() bool { diff --git a/pkg/codegen/schema_constraints.go b/pkg/codegen/schema_constraints.go index c7e58ce4..cf63c8bf 100644 --- a/pkg/codegen/schema_constraints.go +++ b/pkg/codegen/schema_constraints.go @@ -105,6 +105,11 @@ func (c Constraints) Count() int { return count } +// hasPattern reports whether these constraints carry a non-empty regex pattern. +func (c Constraints) hasPattern() bool { + return c.Pattern != nil && *c.Pattern != "" +} + func newConstraints(schema *base.Schema, opts ConstraintsContext) Constraints { if schema == nil { return Constraints{} diff --git a/pkg/codegen/schema_property.go b/pkg/codegen/schema_property.go index 30cdad7c..67c31d69 100644 --- a/pkg/codegen/schema_property.go +++ b/pkg/codegen/schema_property.go @@ -162,6 +162,14 @@ func (p Property) needsCustomValidation() bool { return true } +// needsPatternValidation reports whether this property carries an enforceable regex pattern +// (resolved from its schema, including $ref targets). +func (p Property) needsPatternValidation() bool { + return p.Constraints.hasPattern() && + isPatternValidatable(p.Schema.TypeDecl()) && + patternCompiles(*p.Constraints.Pattern) +} + func createPropertyGoFieldName(jsonName string, extensions map[string]any) string { goFieldName := jsonName if extension, ok := extensions[extGoName]; ok { diff --git a/pkg/codegen/schema_validation.go b/pkg/codegen/schema_validation.go index d895a13c..7b15edac 100644 --- a/pkg/codegen/schema_validation.go +++ b/pkg/codegen/schema_validation.go @@ -12,7 +12,10 @@ package codegen import ( "fmt" + "log/slog" + "regexp" "strings" + "sync" ) // This file contains all validation generation logic for GoSchema. @@ -37,6 +40,20 @@ const ( errMsgMapMinPropsNil = "must have at least %d properties, got 0" ) +var ( + patternCompileMu sync.Mutex + patternCompileCache = map[string]bool{} +) + +// Go types a string pattern can't apply to: non-string-convertible, or a regex over raw bytes is meaningless. +var nonPatternStringTypes = map[string]bool{ + "time.Time": true, + "uuid.UUID": true, + "runtime.File": true, + "runtime.Date": true, + "json.RawMessage": true, +} + // Code generation helpers func returnNilIfEmptyErrors() string { return "if len(errors) == 0 {\n return nil\n}\nreturn errors" @@ -54,84 +71,29 @@ func declareErrorsVar() string { return "var errors runtime.ValidationErrors" } -// ValidateDecl generates the body of the Validate() method for this schema. -// It returns the Go code that should appear inside the Validate() method. -// The alias parameter is the receiver variable name (e.g., "p" for "func (p Person) Validate()"). -// The validatorVar parameter is the name of the validator variable to use (e.g., "bodyTypesValidate"). -func (s GoSchema) ValidateDecl(alias string, validatorVar string) string { - return s.ValidateDeclWithOptions(alias, validatorVar, false) -} - -// ValidateDeclWithOptions generates the body of the Validate() method for this schema with options. -// The forceSimple parameter forces the use of simple validation (validate.Struct()) even for complex types. -func (s GoSchema) ValidateDeclWithOptions(alias string, validatorVar string, forceSimple bool) string { - // If forceSimple is true, always use simple validation for structs - if forceSimple && s.isStructType() { - return s.generateSimpleStructValidation(alias, validatorVar) - } - - // OPTIMIZATION: If this is a struct with no unions anywhere in its tree, - // AND no properties need custom validation (like RefTypes), - // we can use the simple validate.Struct() approach instead of custom validation. - // This is much cleaner and more efficient. - if s.canUseSimpleStructValidation() { - return s.generateSimpleStructValidation(alias, validatorVar) - } - - // Handle array types - if s.isArrayType() { - return s.generateArrayValidation(alias, validatorVar) - } - - // If this schema has a RefType set, it means it's a reference to another type - // In this case, we should delegate validation to the underlying type - if s.isRefTypeDelegation() { - return s.generateRefTypeDelegation(alias) - } - - // If this schema has properties but GoType is a reference to another type - // (not a struct/map/slice), delegate to the underlying type - if s.isTypeAliasDelegation() { - return s.generateTypeAliasDelegation(alias) - } - - // Handle map types (from additionalProperties) - if s.isMapType() && !s.hasCustomValidation() { - return s.generateMapValidation(alias, validatorVar) - } - - // For other non-struct types (slices, primitives) without custom validation - if !s.hasCustomValidation() { - return s.generateNonStructValidation(alias, validatorVar) - } - - // Generate custom validation for struct properties - return s.generateCustomPropertyValidation(alias, validatorVar) -} - // Validation generators (in order of appearance in ValidateDecl) // generateSimpleStructValidation generates validation using validator.Struct() -func (s GoSchema) generateSimpleStructValidation(alias, validatorVar string) string { +func generateSimpleStructValidation(s GoSchema, alias, validatorVar string) string { return returnNilIfNoError(validatorVar, alias) } // generateRefTypeDelegation generates validation that delegates to a RefType -func (s GoSchema) generateRefTypeDelegation(alias string) string { +func generateRefTypeDelegation(s GoSchema, alias string) string { // Cast to the underlying type to avoid infinite recursion // (the current type might implement Validator itself) return delegateToValidator(fmt.Sprintf("%s(%s)", s.RefType, alias)) } // generateTypeAliasDelegation generates validation that delegates to the underlying type -func (s GoSchema) generateTypeAliasDelegation(alias string) string { +func generateTypeAliasDelegation(s GoSchema, alias string) string { // This is a type definition like "type X Y" where Y is another type // Cast to the underlying type to avoid infinite recursion return delegateToValidator(fmt.Sprintf("%s(%s)", s.TypeDecl(), alias)) } // generateArrayValidation generates validation for array types -func (s GoSchema) generateArrayValidation(alias, validatorVar string) string { +func generateArrayValidation(s GoSchema, alias, validatorVar string) string { var lines []string // Allow nil if: @@ -190,16 +152,23 @@ func (s GoSchema) generateArrayValidation(alias, validatorVar string) string { } // Validate array items if they need validation if s.ArrayType != nil && s.ArrayType.NeedsValidation() { + hasItemTags := len(s.ArrayType.Constraints.ValidationTags) > 0 + hasItemPattern := s.ArrayType.needsPatternValidation() lines = append(lines, "for i, item := range "+alias+" {") // If items have validation tags, use validator.Var() - if len(s.ArrayType.Constraints.ValidationTags) > 0 { + if hasItemTags { tags := strings.Join(s.ArrayType.Constraints.ValidationTags, ",") lines = append(lines, fmt.Sprintf(" if err := %s.Var(item, \"%s\"); err != nil {", validatorVar, tags)) lines = append(lines, " errors = errors.Append(fmt.Sprintf(\"[%d]\", i), err)") lines = append(lines, " }") - } else { - // Otherwise, try to call Validate() method (for RefTypes, structs, unions) + } + // Enforce a regex pattern on string items. + if hasItemPattern { + lines = append(lines, generateElementPatternLines("item", "fmt.Sprintf(\"[%d]\", i)", s.ArrayType)...) + } + // Otherwise, try to call Validate() method (for RefTypes, structs, unions) + if !hasItemTags && !hasItemPattern { lines = append(lines, " if v, ok := any(item).(runtime.Validator); ok {") lines = append(lines, " if err := v.Validate(); err != nil {") lines = append(lines, " errors = errors.Append(fmt.Sprintf(\"[%d]\", i), err)") @@ -220,7 +189,7 @@ func (s GoSchema) generateArrayValidation(alias, validatorVar string) string { } // generateMapValidation generates validation for map types -func (s GoSchema) generateMapValidation(alias, validatorVar string) string { +func generateMapValidation(s GoSchema, alias, validatorVar string) string { var lines []string // Only allow nil if explicitly nullable OR if there's no minProperties constraint @@ -261,6 +230,7 @@ func (s GoSchema) generateMapValidation(alias, validatorVar string) string { } lines = append(lines, "}") } + // Check MaxProperties constraint if s.Constraints.MaxProperties != nil { errMsg := fmt.Sprintf(errMsgMapMaxProps, *s.Constraints.MaxProperties) @@ -272,15 +242,23 @@ func (s GoSchema) generateMapValidation(alias, validatorVar string) string { } lines = append(lines, "}") } + // Validate each value if it needs validation if s.AdditionalPropertiesType != nil { - // Check if map values have validation tags (for primitive types) - if len(s.AdditionalPropertiesType.Constraints.ValidationTags) > 0 { - tags := strings.Join(s.AdditionalPropertiesType.Constraints.ValidationTags, ",") + hasValTags := len(s.AdditionalPropertiesType.Constraints.ValidationTags) > 0 + hasValPattern := s.AdditionalPropertiesType.needsPatternValidation() + if hasValTags || hasValPattern { + // Primitive values: validator tags and/or a regex pattern. lines = append(lines, "for k, v := range "+alias+" {") - lines = append(lines, fmt.Sprintf(" if err := %s.Var(v, \"%s\"); err != nil {", validatorVar, tags)) - lines = append(lines, " errors = errors.Append(k, err)") - lines = append(lines, " }") + if hasValTags { + tags := strings.Join(s.AdditionalPropertiesType.Constraints.ValidationTags, ",") + lines = append(lines, fmt.Sprintf(" if err := %s.Var(v, \"%s\"); err != nil {", validatorVar, tags)) + lines = append(lines, " errors = errors.Append(k, err)") + lines = append(lines, " }") + } + if hasValPattern { + lines = append(lines, generateElementPatternLines("v", "k", s.AdditionalPropertiesType)...) + } lines = append(lines, "}") lines = append(lines, returnNilIfEmptyErrors()) } else if s.AdditionalPropertiesType.NeedsValidation() { @@ -309,14 +287,32 @@ func (s GoSchema) generateMapValidation(alias, validatorVar string) string { } // generateNonStructValidation generates validation for non-struct types (slices, primitives) -func (s GoSchema) generateNonStructValidation(alias, validatorVar string) string { +func generateNonStructValidation(s GoSchema, alias, validatorVar string) string { typeDecl := s.TypeDecl() var lines []string // For other non-struct types (slices, primitives) if strings.HasPrefix(typeDecl, "[]") || len(s.Properties) == 0 { + hasTags := len(s.Constraints.ValidationTags) > 0 + hasPattern := s.needsPatternValidation() + + // When both tags and a pattern apply, collect errors so neither check + // short-circuits the other. + if hasTags && hasPattern { + tags := strings.Join(s.Constraints.ValidationTags, ",") + lines = append(lines, declareErrorsVar()) + lines = append(lines, fmt.Sprintf("if err := %s.Var(%s, \"%s\"); err != nil {", validatorVar, alias, tags)) + lines = append(lines, " errors = errors.Append(\"\", err)") + lines = append(lines, "}") + lines = append(lines, fmt.Sprintf("if err := runtime.ValidatePattern(%s, %s); err != nil {", alias, goStringLiteral(*s.Constraints.Pattern))) + lines = append(lines, " errors = errors.Append(\"\", err)") + lines = append(lines, "}") + lines = append(lines, returnNilIfEmptyErrors()) + return strings.Join(lines, "\n") + } + // Check if the schema itself has validation tags (for primitive types) - if len(s.Constraints.ValidationTags) > 0 { + if hasTags { tags := strings.Join(s.Constraints.ValidationTags, ",") lines = append(lines, fmt.Sprintf("if err := %s.Var(%s, \"%s\"); err != nil {", validatorVar, alias, tags)) lines = append(lines, " return err") @@ -324,6 +320,16 @@ func (s GoSchema) generateNonStructValidation(alias, validatorVar string) string lines = append(lines, returnNil) return strings.Join(lines, "\n") } + + // Otherwise enforce just the regex pattern, if present. + if hasPattern { + lines = append(lines, fmt.Sprintf("if err := runtime.ValidatePattern(%s, %s); err != nil {", alias, goStringLiteral(*s.Constraints.Pattern))) + lines = append(lines, " return err") + lines = append(lines, "}") + lines = append(lines, returnNil) + return strings.Join(lines, "\n") + } + return returnNil } @@ -332,7 +338,7 @@ func (s GoSchema) generateNonStructValidation(alias, validatorVar string) string } // generateCustomPropertyValidation generates custom validation for struct properties -func (s GoSchema) generateCustomPropertyValidation(alias, validatorVar string) string { +func generateCustomPropertyValidation(s GoSchema, alias, validatorVar string) string { var lines []string // Generate custom validation for each property @@ -384,20 +390,9 @@ func (s GoSchema) generateCustomPropertyValidation(alias, validatorVar string) s } } } - } else if len(prop.Constraints.ValidationTags) > 0 { - // Property with validation tags - use Var() - tags := strings.Join(prop.Constraints.ValidationTags, ",") - if prop.IsPointerType() { - lines = append(lines, fmt.Sprintf("if %s.%s != nil {", alias, prop.GoName)) - lines = append(lines, fmt.Sprintf(" if err := %s.Var(%s.%s, \"%s\"); err != nil {", validatorVar, alias, prop.GoName, tags)) - lines = append(lines, fmt.Sprintf(" errors = errors.Append(\"%s\", err)", prop.GoName)) - lines = append(lines, " }") - lines = append(lines, "}") - } else { - lines = append(lines, fmt.Sprintf("if err := %s.Var(%s.%s, \"%s\"); err != nil {", validatorVar, alias, prop.GoName, tags)) - lines = append(lines, fmt.Sprintf(" errors = errors.Append(\"%s\", err)", prop.GoName)) - lines = append(lines, "}") - } + } else { + // Primitive property: may carry validator tags and/or a regex pattern. + lines = append(lines, generatePrimitivePropertyValidation(alias, prop, validatorVar)...) } } @@ -405,22 +400,83 @@ func (s GoSchema) generateCustomPropertyValidation(alias, validatorVar string) s return strings.Join(lines, "\n") } +// generatePrimitivePropertyValidation emits Var() tag checks and/or a ValidatePattern() check +// for a primitive property. The pattern value is passed as-is: runtime.ValidatePattern derefs +// pointers and skips nil/non-string, so no type conversion or nil-guard is needed here. +func generatePrimitivePropertyValidation(alias string, prop Property, validatorVar string) []string { + hasTags := len(prop.Constraints.ValidationTags) > 0 + hasPattern := prop.needsPatternValidation() + if !hasTags && !hasPattern { + return nil + } + + field := fmt.Sprintf("%s.%s", alias, prop.GoName) + var lines []string + + if hasTags { + tags := strings.Join(prop.Constraints.ValidationTags, ",") + if prop.IsPointerType() { + lines = append(lines, + fmt.Sprintf("if %s != nil {", field), + fmt.Sprintf(" if err := %s.Var(%s, \"%s\"); err != nil {", validatorVar, field, tags), + fmt.Sprintf(" errors = errors.Append(\"%s\", err)", prop.GoName), + " }", + "}") + } else { + lines = append(lines, + fmt.Sprintf("if err := %s.Var(%s, \"%s\"); err != nil {", validatorVar, field, tags), + fmt.Sprintf(" errors = errors.Append(\"%s\", err)", prop.GoName), + "}") + } + } + + if hasPattern { + lines = append(lines, + fmt.Sprintf("if err := runtime.ValidatePattern(%s, %s); err != nil {", field, goStringLiteral(*prop.Constraints.Pattern)), + fmt.Sprintf(" errors = errors.Append(\"%s\", err)", prop.GoName), + "}") + } + + return lines +} + +// generateElementPatternLines emits a ValidatePattern check for one collection element +// (array item or map value). The element is passed as-is; runtime.ValidatePattern derefs +// pointers and skips nil/non-string. keyExpr is the error field-key expression. +func generateElementPatternLines(itemExpr, keyExpr string, elem *GoSchema) []string { + return []string{ + fmt.Sprintf(" if err := runtime.ValidatePattern(%s, %s); err != nil {", itemExpr, goStringLiteral(*elem.Constraints.Pattern)), + fmt.Sprintf(" errors = errors.Append(%s, err)", keyExpr), + " }", + } +} + // generateArrayPropertyValidation generates validation code for an array property func generateArrayPropertyValidation(alias string, prop Property, validatorVar string) []string { var lines []string fieldAccess := fmt.Sprintf("%s.%s", alias, prop.GoName) // Check for nil before iterating + hasItemTags := len(prop.Schema.ArrayType.Constraints.ValidationTags) > 0 + hasItemPattern := prop.Schema.ArrayType.needsPatternValidation() lines = append(lines, fmt.Sprintf("for i, item := range %s {", fieldAccess)) // If items have validation tags, use validator.Var() - if len(prop.Schema.ArrayType.Constraints.ValidationTags) > 0 { + if hasItemTags { tags := strings.Join(prop.Schema.ArrayType.Constraints.ValidationTags, ",") lines = append(lines, fmt.Sprintf(" if err := %s.Var(item, \"%s\"); err != nil {", validatorVar, tags)) lines = append(lines, fmt.Sprintf(" errors = errors.Append(fmt.Sprintf(\"%s[%%d]\", i), err)", prop.GoName)) lines = append(lines, " }") - } else { - // Otherwise, try to call Validate() method (for RefTypes, structs, unions) + } + + // Enforce a regex pattern on string items. + if hasItemPattern { + keyExpr := fmt.Sprintf("fmt.Sprintf(\"%s[%%d]\", i)", prop.GoName) + lines = append(lines, generateElementPatternLines("item", keyExpr, prop.Schema.ArrayType)...) + } + + // Otherwise, try to call Validate() method (for RefTypes, structs, unions) + if !hasItemTags && !hasItemPattern { lines = append(lines, " if v, ok := any(item).(runtime.Validator); ok {") lines = append(lines, " if err := v.Validate(); err != nil {") lines = append(lines, fmt.Sprintf(" errors = errors.Append(fmt.Sprintf(\"%s[%%d]\", i), err)", prop.GoName)) @@ -438,16 +494,26 @@ func generateMapPropertyValidation(alias string, prop Property, validatorVar str fieldAccess := fmt.Sprintf("%s.%s", alias, prop.GoName) // Iterate over map values + hasValTags := len(prop.Schema.AdditionalPropertiesType.Constraints.ValidationTags) > 0 + hasValPattern := prop.Schema.AdditionalPropertiesType.needsPatternValidation() lines = append(lines, fmt.Sprintf("for k, v := range %s {", fieldAccess)) // If values have validation tags, use validator.Var() - if len(prop.Schema.AdditionalPropertiesType.Constraints.ValidationTags) > 0 { + if hasValTags { tags := strings.Join(prop.Schema.AdditionalPropertiesType.Constraints.ValidationTags, ",") lines = append(lines, fmt.Sprintf(" if err := %s.Var(v, \"%s\"); err != nil {", validatorVar, tags)) lines = append(lines, fmt.Sprintf(" errors = errors.Append(fmt.Sprintf(\"%s[%%s]\", k), err)", prop.GoName)) lines = append(lines, " }") - } else { - // Otherwise, try to call Validate() method (for RefTypes, structs, unions) + } + + // Enforce a regex pattern on string values. + if hasValPattern { + keyExpr := fmt.Sprintf("fmt.Sprintf(\"%s[%%s]\", k)", prop.GoName) + lines = append(lines, generateElementPatternLines("v", keyExpr, prop.Schema.AdditionalPropertiesType)...) + } + + // Otherwise, try to call Validate() method (for RefTypes, structs, unions) + if !hasValTags && !hasValPattern { lines = append(lines, " if validator, ok := any(v).(runtime.Validator); ok {") lines = append(lines, " if err := validator.Validate(); err != nil {") lines = append(lines, fmt.Sprintf(" errors = errors.Append(fmt.Sprintf(\"%s[%%s]\", k), err)", prop.GoName)) @@ -459,23 +525,22 @@ func generateMapPropertyValidation(alias string, prop Property, validatorVar str return lines } -// Helper predicates - // isStructType checks if this schema represents a struct type -func (s GoSchema) isStructType() bool { +func isStructType(s GoSchema) bool { typeDecl := s.TypeDecl() return strings.HasPrefix(typeDecl, "struct") && len(s.Properties) > 0 } // canUseSimpleStructValidation checks if we can use the optimized validator.Struct() approach -func (s GoSchema) canUseSimpleStructValidation() bool { +func canUseSimpleStructValidation(s GoSchema) bool { typeDecl := s.TypeDecl() if !strings.HasPrefix(typeDecl, "struct") || len(s.Properties) == 0 || s.ContainsUnions() { return false } - // Check if any property needs custom validation + + // A property needing custom validation or a regex pattern rules out validator.Struct(). for _, prop := range s.Properties { - if prop.needsCustomValidation() { + if prop.needsCustomValidation() || prop.needsPatternValidation() { return false } } @@ -483,17 +548,17 @@ func (s GoSchema) canUseSimpleStructValidation() bool { } // isArrayType checks if this schema represents an array type -func (s GoSchema) isArrayType() bool { +func isArrayType(s GoSchema) bool { return s.ArrayType != nil && strings.HasPrefix(s.TypeDecl(), "[]") } // isRefTypeDelegation checks if this schema should delegate to a RefType -func (s GoSchema) isRefTypeDelegation() bool { +func isRefTypeDelegation(s GoSchema) bool { return s.RefType != "" && !s.IsExternalRef() } // isTypeAliasDelegation checks if this schema is a type alias that should delegate -func (s GoSchema) isTypeAliasDelegation() bool { +func isTypeAliasDelegation(s GoSchema) bool { typeDecl := s.TypeDecl() return len(s.Properties) > 0 && !strings.HasPrefix(typeDecl, "struct") && @@ -502,17 +567,63 @@ func (s GoSchema) isTypeAliasDelegation() bool { } // isMapType checks if this schema represents a map type -func (s GoSchema) isMapType() bool { +func isMapType(s GoSchema) bool { typeDecl := s.TypeDecl() return strings.HasPrefix(typeDecl, "map[") } -// hasCustomValidation checks if any property needs custom validation -func (s GoSchema) hasCustomValidation() bool { +// hasCustomValidation reports whether any property needs custom validation, including a regex pattern. +func hasCustomValidation(s GoSchema) bool { for _, prop := range s.Properties { - if prop.needsCustomValidation() { + if prop.needsCustomValidation() || prop.needsPatternValidation() { return true } } return false } + +// isPatternValidatable reports whether emitting a regex check for goType is worthwhile. +// It filters obvious non-strings (slices, maps, non-string primitives) at generation time; +// runtime.ValidatePattern is the final authority and safely skips anything non-string-kinded. +func isPatternValidatable(goType string) bool { + base := strings.TrimPrefix(goType, "*") + if strings.HasPrefix(base, "[]") || strings.HasPrefix(base, "map[") { + return false + } + + if nonPatternStringTypes[base] { + return false + } + + // Non-string primitives (int, bool, time.Time, ...) can't carry a regex. + if isPrimitiveType(base) && base != "string" { + return false + } + return true +} + +// goStringLiteral renders s as a Go literal, preferring a raw string unless s contains a backtick. +func goStringLiteral(s string) string { + if !strings.ContainsRune(s, '`') { + return "`" + s + "`" + } + return fmt.Sprintf("%q", s) +} + +// patternCompiles reports (and caches) whether Go's RE2 engine can compile pattern; unsupported ones are skipped with a warning. +func patternCompiles(pattern string) bool { + patternCompileMu.Lock() + defer patternCompileMu.Unlock() + if ok, seen := patternCompileCache[pattern]; seen { + return ok + } + + _, err := regexp.Compile(pattern) + ok := err == nil + patternCompileCache[pattern] = ok + if !ok { + slog.Warn("skipping unsupported regex pattern in generated Validate(): Go's regexp engine (RE2) could not compile it", + "pattern", pattern, "error", err) + } + return ok +} diff --git a/pkg/codegen/schema_validation_test.go b/pkg/codegen/schema_validation_test.go index 805223bf..3549e15b 100644 --- a/pkg/codegen/schema_validation_test.go +++ b/pkg/codegen/schema_validation_test.go @@ -1067,6 +1067,231 @@ func TestGoSchema_ValidateDecl_IntegerWithValidationTags(t *testing.T) { assertCodeEqual(t, expected, result) } +func TestGoSchema_ValidateDecl_StandalonePrimitiveWithPattern(t *testing.T) { + // A standalone string type carrying only a regex pattern. + schema := GoSchema{ + GoType: "string", + Constraints: Constraints{Pattern: ptr(`^[a-z]+$`)}, + } + + result := schema.ValidateDecl("s", "validate") + expected := "if err := runtime.ValidatePattern(s, `^[a-z]+$`); err != nil {\n" + + " return err\n" + + "}\n" + + "return nil" + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDecl_StructWithStringPatternProperty(t *testing.T) { + // Required string property with both a "required" tag and a regex pattern. + schema := GoSchema{ + GoType: "struct { Code string }", + Properties: []Property{ + { + GoName: "Code", + JsonFieldName: "code", + Schema: GoSchema{GoType: "string"}, + Constraints: Constraints{ + ValidationTags: []string{"required"}, + Pattern: ptr(`^[A-Z]{3}$`), + }, + }, + }, + } + + result := schema.ValidateDecl("t", "typesValidator") + expected := "var errors runtime.ValidationErrors\n" + + "if err := typesValidator.Var(t.Code, \"required\"); err != nil {\n" + + " errors = errors.Append(\"Code\", err)\n" + + "}\n" + + "if err := runtime.ValidatePattern(t.Code, `^[A-Z]{3}$`); err != nil {\n" + + " errors = errors.Append(\"Code\", err)\n" + + "}\n" + + "if len(errors) == 0 {\n return nil\n}\nreturn errors" + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDecl_StructWithPointerStringPatternProperty(t *testing.T) { + // Optional (pointer) string property with tags and a pattern; both checks + // share a single nil-guard and the pattern is checked against the deref. + schema := GoSchema{ + GoType: "struct { Slug *string }", + Properties: []Property{ + { + GoName: "Slug", + JsonFieldName: "slug", + Schema: GoSchema{GoType: "string"}, + Constraints: Constraints{ + ValidationTags: []string{"omitempty", "min=2"}, + Pattern: ptr(`^[a-z-]+$`), + Nullable: ptr(true), + }, + }, + }, + } + + result := schema.ValidateDecl("t", "typesValidator") + expected := "var errors runtime.ValidationErrors\n" + + "if t.Slug != nil {\n" + + " if err := typesValidator.Var(t.Slug, \"omitempty,min=2\"); err != nil {\n" + + " errors = errors.Append(\"Slug\", err)\n" + + " }\n" + + "}\n" + + "if err := runtime.ValidatePattern(t.Slug, `^[a-z-]+$`); err != nil {\n" + + " errors = errors.Append(\"Slug\", err)\n" + + "}\n" + + "if len(errors) == 0 {\n return nil\n}\nreturn errors" + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDecl_StructWithNamedStringPatternProperty(t *testing.T) { + // Property referencing a named string type (e.g. a "type: string" component) + // with a pattern: the value is converted with string(...) before matching. + schema := GoSchema{ + GoType: "struct { Ref *Sku }", + Properties: []Property{ + { + GoName: "Ref", + JsonFieldName: "ref", + // A $ref to a "type: string" component resolves to a named type + // that is flagged as a primitive alias, so the pattern is checked + // inline rather than via a delegated Validate() call. + Schema: GoSchema{GoType: "Sku", IsPrimitiveAlias: true}, + Constraints: Constraints{ + Pattern: ptr(`^SKU-\d+$`), + Nullable: ptr(true), + }, + }, + }, + } + + result := schema.ValidateDecl("t", "typesValidator") + expected := "var errors runtime.ValidationErrors\n" + + "if err := runtime.ValidatePattern(t.Ref, `^SKU-\\d+$`); err != nil {\n" + + " errors = errors.Append(\"Ref\", err)\n" + + "}\n" + + "if len(errors) == 0 {\n return nil\n}\nreturn errors" + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDeclWithOptions_SimpleModeSkipsPattern(t *testing.T) { + // Simple mode validates structs via validate.Struct(), which can't enforce a + // regex, so the pattern is intentionally not validated there. Per-field/ + // per-item contexts (full-mode structs, arrays, maps) still validate it. + schema := GoSchema{ + GoType: "struct { Code string }", + Properties: []Property{ + { + GoName: "Code", + JsonFieldName: "code", + Schema: GoSchema{GoType: "string"}, + Constraints: Constraints{ + ValidationTags: []string{"required"}, + Pattern: ptr(`^[A-Z]{3}$`), + }, + }, + }, + } + + result := schema.ValidateDeclWithOptions("s", "validate", true) + assertCodeEqual(t, `return runtime.ConvertValidatorError(validate.Struct(s))`, result) +} + +func TestGoSchema_ValidateDecl_NonStringTypeWithPatternIgnored(t *testing.T) { + // A pattern on a non-string Go type (time.Time) is meaningless and must be + // ignored; the struct falls back to simple validate.Struct() validation. + schema := GoSchema{ + GoType: "struct { When time.Time }", + Properties: []Property{ + { + GoName: "When", + JsonFieldName: "when", + Schema: GoSchema{GoType: "time.Time"}, + Constraints: Constraints{Pattern: ptr(`^x$`)}, + }, + }, + } + + result := schema.ValidateDecl("s", "validate") + expected := `return runtime.ConvertValidatorError(validate.Struct(s))` + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDecl_UncompilablePatternSkipped(t *testing.T) { + // Lookahead is valid ECMA-262 but cannot be compiled by Go's RE2 engine, so + // no pattern check is generated (a warning is logged instead). + schema := GoSchema{ + GoType: "string", + Constraints: Constraints{Pattern: ptr(`(?=lookahead)`)}, + } + + result := schema.ValidateDecl("s", "validate") + assertCodeEqual(t, `return nil`, result) +} + +func TestGoSchema_ValidateDecl_ArrayWithPatternItems(t *testing.T) { + schema := GoSchema{ + GoType: "[]string", + ArrayType: &GoSchema{ + GoType: "string", + Constraints: Constraints{Pattern: ptr(`^[a-z]+$`)}, + }, + } + + result := schema.ValidateDecl("p", "validate") + expected := "var errors runtime.ValidationErrors\n" + + "for i, item := range p {\n" + + " if err := runtime.ValidatePattern(item, `^[a-z]+$`); err != nil {\n" + + " errors = errors.Append(fmt.Sprintf(\"[%d]\", i), err)\n" + + " }\n" + + "}\n" + + "if len(errors) == 0 {\n return nil\n}\nreturn errors" + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDecl_MapWithPatternValues(t *testing.T) { + schema := GoSchema{ + GoType: "map[string]string", + AdditionalPropertiesType: &GoSchema{ + GoType: "string", + Constraints: Constraints{Pattern: ptr(`^[A-Z]{2}$`)}, + }, + } + + result := schema.ValidateDecl("m", "validate") + expected := "var errors runtime.ValidationErrors\n" + + "for k, v := range m {\n" + + " if err := runtime.ValidatePattern(v, `^[A-Z]{2}$`); err != nil {\n" + + " errors = errors.Append(k, err)\n" + + " }\n" + + "}\n" + + "if len(errors) == 0 {\n return nil\n}\nreturn errors" + assertCodeEqual(t, expected, result) +} + +func TestGoSchema_ValidateDecl_ArrayWithNullablePatternItems(t *testing.T) { + // Array of nullable strings ([]*string): pointer items are nil-guarded and + // dereferenced before pattern matching. + schema := GoSchema{ + GoType: "[]*string", + ArrayType: &GoSchema{ + GoType: "string", + Constraints: Constraints{Pattern: ptr(`^[a-z]+$`)}, + OpenAPISchema: &base.Schema{Nullable: ptr(true)}, + }, + } + + result := schema.ValidateDecl("p", "validate") + expected := "var errors runtime.ValidationErrors\n" + + "for i, item := range p {\n" + + " if err := runtime.ValidatePattern(item, `^[a-z]+$`); err != nil {\n" + + " errors = errors.Append(fmt.Sprintf(\"[%d]\", i), err)\n" + + " }\n" + + "}\n" + + "if len(errors) == 0 {\n return nil\n}\nreturn errors" + assertCodeEqual(t, expected, result) +} + // TestGoSchema_NeedsValidation_StructWithArrayOfCustomTypes tests that a struct // with only array properties whose item types need validation correctly returns // true for NeedsValidation(). This is a regression test for the bug where diff --git a/pkg/runtime/validation.go b/pkg/runtime/validation.go index 24bbd212..8bff689b 100644 --- a/pkg/runtime/validation.go +++ b/pkg/runtime/validation.go @@ -16,7 +16,10 @@ package runtime import ( "errors" + "fmt" "reflect" + "regexp" + "sync" "github.com/go-playground/validator/v10" ) @@ -26,6 +29,30 @@ type Validator interface { Validate() error } +// patternCache memoizes compiled regexps by source pattern; safe for concurrent use. +var patternCache sync.Map // map[string]*regexp.Regexp + +// ValidatePattern reports whether value matches the OpenAPI `pattern` regex, for generated +// Validate() methods. value may be a string, a named string type, or a pointer to either; +// nil, non-string, and empty values are skipped (pair with `required` to reject empties). +// An uncompilable pattern yields a ValidationError. +func ValidatePattern(value any, pattern string) error { + s, ok := patternInput(value) + if !ok || s == "" { + return nil + } + + re, err := compilePattern(pattern) + if err != nil { + return NewValidationError("", fmt.Sprintf("has an unusable pattern '%s': %s", pattern, err)) + } + + if !re.MatchString(s) { + return NewValidationError("", fmt.Sprintf("must match pattern '%s'", pattern)) + } + return nil +} + // RegisterCustomTypeFunc registers a custom type function with the validator // to extract values from types that have a Value() interface{} method. // This is useful for union types (like Either) where only the active variant @@ -76,3 +103,53 @@ func ConvertValidatorError(err error) error { // Use the existing NewValidationErrorsFromError which handles validator errors properly return NewValidationErrorsFromError(err) } + +// compilePattern returns the compiled form of pattern, caching the result. +func compilePattern(pattern string) (*regexp.Regexp, error) { + // patternCache holds any; assert with comma-ok so a future change caching a + // non-*regexp.Regexp recompiles instead of panicking. + if cached, ok := patternCache.Load(pattern); ok { + if re, ok := cached.(*regexp.Regexp); ok { + return re, nil + } + } + + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + actual, _ := patternCache.LoadOrStore(pattern, re) + if cached, ok := actual.(*regexp.Regexp); ok { + return cached, nil + } + return re, nil +} + +// patternInput extracts the string to match from value, dereferencing one pointer +// level and accepting named string types (e.g. type Email string). It returns false +// when value is nil or not string-kinded, so a regex over a non-string (slice, struct, +// number, ...) is skipped rather than misapplied or causing a type error. +func patternInput(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case *string: + if v == nil { + return "", false + } + return *v, true + } + + rv := reflect.ValueOf(value) + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return "", false + } + rv = rv.Elem() + } + if rv.Kind() == reflect.String { + return rv.String(), true + } + return "", false +} diff --git a/pkg/runtime/validation_test.go b/pkg/runtime/validation_test.go index c040aa21..596c4649 100644 --- a/pkg/runtime/validation_test.go +++ b/pkg/runtime/validation_test.go @@ -229,3 +229,81 @@ func TestConvertValidatorError(t *testing.T) { assert.Len(t, unwrapped, 2) }) } + +func TestValidatePattern(t *testing.T) { + t.Run("matching value passes", func(t *testing.T) { + assert.NoError(t, ValidatePattern("ABC", `^[A-Z]{3}$`)) + }) + + t.Run("non-matching value fails", func(t *testing.T) { + err := ValidatePattern("abc", `^[A-Z]{3}$`) + require.Error(t, err) + assert.Contains(t, err.Error(), "must match pattern") + assert.Contains(t, err.Error(), `^[A-Z]{3}$`) + }) + + t.Run("empty value is skipped", func(t *testing.T) { + // An empty value is treated as absent; a required constraint is + // responsible for rejecting it. + assert.NoError(t, ValidatePattern("", `^[A-Z]{3}$`)) + }) + + t.Run("returned error is a ValidationError", func(t *testing.T) { + err := ValidatePattern("abc", `^[A-Z]{3}$`) + var ve ValidationError + require.True(t, errors.As(err, &ve)) + assert.Empty(t, ve.Field, "field should be supplied by the caller via Append") + }) + + t.Run("field is applied by Append", func(t *testing.T) { + var verrs ValidationErrors + verrs = verrs.Append("Code", ValidatePattern("abc", `^[A-Z]{3}$`)) + require.Len(t, verrs, 1) + assert.Equal(t, "Code", verrs[0].Field) + }) + + t.Run("uncompilable pattern reports an error", func(t *testing.T) { + // Lookahead is valid ECMA-262 but unsupported by Go's RE2 engine. + err := ValidatePattern("anything", `(?=x)`) + require.Error(t, err) + assert.Contains(t, err.Error(), "unusable pattern") + }) + + t.Run("dereferences pointers", func(t *testing.T) { + s := "abc" + assert.NoError(t, ValidatePattern(&s, `^[a-z]+$`)) + bad := "ABC" + assert.Error(t, ValidatePattern(&bad, `^[a-z]+$`)) + }) + + t.Run("nil pointer is skipped", func(t *testing.T) { + var s *string + assert.NoError(t, ValidatePattern(s, `^[a-z]+$`)) + }) + + t.Run("named string types are matched", func(t *testing.T) { + type code string + assert.NoError(t, ValidatePattern(code("abc"), `^[a-z]+$`)) + assert.Error(t, ValidatePattern(code("ABC"), `^[a-z]+$`)) + c := code("abc") + assert.NoError(t, ValidatePattern(&c, `^[a-z]+$`)) + }) + + t.Run("non-string values are skipped", func(t *testing.T) { + // A regex can't apply to a slice/struct/number, so these pass rather than error. + assert.NoError(t, ValidatePattern([]string{"x"}, `^[a-z]+$`)) + assert.NoError(t, ValidatePattern(42, `^[a-z]+$`)) + assert.NoError(t, ValidatePattern(struct{ X int }{}, `^[a-z]+$`)) + }) + + t.Run("compiled pattern is cached", func(t *testing.T) { + const pattern = `^cache-[0-9]+$` + require.NoError(t, ValidatePattern("cache-1", pattern)) + + // Second call hits the cache and behaves identically. + require.NoError(t, ValidatePattern("cache-2", pattern)) + + _, ok := patternCache.Load(pattern) + assert.True(t, ok, "pattern should be cached after first use") + }) +}