Skip to content

Commit

Permalink
Add support for adding validations for nested subschemas through mark…
Browse files Browse the repository at this point in the history
…er comments

adds powerful feature to marker comments to be able to add validations attributed to subschemas. Does not support removing validations from subschemas
  • Loading branch information
alexzielenski committed May 21, 2024
1 parent 2b4e2b0 commit 631461f
Show file tree
Hide file tree
Showing 4 changed files with 666 additions and 28 deletions.
187 changes: 171 additions & 16 deletions pkg/generators/markers.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,29 @@ func (c *CELTag) Validate() error {
// - +listMapKeys
// - +mapType
type commentTags struct {
spec.SchemaProps
Nullable *bool `json:"nullable,omitempty"`
Format *string `json:"format,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
ExclusiveMaximum *bool `json:"exclusiveMaximum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
ExclusiveMinimum *bool `json:"exclusiveMinimum,omitempty"`
MaxLength *int64 `json:"maxLength,omitempty"`
MinLength *int64 `json:"minLength,omitempty"`
Pattern *string `json:"pattern,omitempty"`
MaxItems *int64 `json:"maxItems,omitempty"`
MinItems *int64 `json:"minItems,omitempty"`
UniqueItems *bool `json:"uniqueItems,omitempty"`
MultipleOf *float64 `json:"multipleOf,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
MaxProperties *int64 `json:"maxProperties,omitempty"`
MinProperties *int64 `json:"minProperties,omitempty"`

// Nested commentTags for extending the schemas of subfields at point-of-use
// when you cant annotate them directly. Cannot be used to add properties
// or remove validations on the overridden schema.
Items *commentTags `json:"items,omitempty"`
Properties map[string]*commentTags `json:"properties,omitempty"`
AdditionalProperties *commentTags `json:"additionalProperties,omitempty"`

CEL []CELTag `json:"cel,omitempty"`

Expand All @@ -86,9 +108,75 @@ type commentTags struct {

// Returns the schema for the given CommentTags instance.
// This is the final authoritative schema for the comment tags
func (c commentTags) ValidationSchema() (*spec.Schema, error) {
func (c *commentTags) ValidationSchema() (*spec.Schema, error) {
if c == nil {
return nil, nil
}

isNullable := c.Nullable != nil && *c.Nullable
format := ""
if c.Format != nil {
format = *c.Format
}
isExclusiveMaximum := c.ExclusiveMaximum != nil && *c.ExclusiveMaximum
isExclusiveMinimum := c.ExclusiveMinimum != nil && *c.ExclusiveMinimum
isUniqueItems := c.UniqueItems != nil && *c.UniqueItems
pattern := ""
if c.Pattern != nil {
pattern = *c.Pattern
}

var transformedItems *spec.SchemaOrArray
var transformedProperties map[string]spec.Schema
var transformedAdditionalProperties *spec.SchemaOrBool

if c.Items != nil {
items, err := c.Items.ValidationSchema()
if err != nil {
return nil, fmt.Errorf("failed to transform items: %w", err)
}
transformedItems = &spec.SchemaOrArray{Schema: items}
}

if c.Properties != nil {
properties := make(map[string]spec.Schema)
for key, value := range c.Properties {
property, err := value.ValidationSchema()
if err != nil {
return nil, fmt.Errorf("failed to transform property %q: %w", key, err)
}
properties[key] = *property
}
transformedProperties = properties
}

if c.AdditionalProperties != nil {
additionalProperties, err := c.AdditionalProperties.ValidationSchema()
if err != nil {
return nil, fmt.Errorf("failed to transform additionalProperties: %w", err)
}
transformedAdditionalProperties = &spec.SchemaOrBool{Schema: additionalProperties, Allows: true}
}

res := spec.Schema{
SchemaProps: c.SchemaProps,
SchemaProps: spec.SchemaProps{
Nullable: isNullable,
Format: format,
Maximum: c.Maximum,
ExclusiveMaximum: isExclusiveMaximum,
Minimum: c.Minimum,
ExclusiveMinimum: isExclusiveMinimum,
MaxLength: c.MaxLength,
MinLength: c.MinLength,
Pattern: pattern,
MaxItems: c.MaxItems,
MinItems: c.MinItems,
UniqueItems: isUniqueItems,
MultipleOf: c.MultipleOf,
Enum: c.Enum,
MaxProperties: c.MaxProperties,
MinProperties: c.MinProperties,
},
}

if len(c.CEL) > 0 {
Expand All @@ -105,6 +193,18 @@ func (c commentTags) ValidationSchema() (*spec.Schema, error) {
res.VendorExtensible.AddExtension("x-kubernetes-validations", celTagMap)
}

// Dont add structural properties directly to this schema. This schema
// is used only for validation.
if transformedItems != nil || len(transformedProperties) > 0 || transformedAdditionalProperties != nil {
res.AllOf = append(res.AllOf, spec.Schema{
SchemaProps: spec.SchemaProps{
Items: transformedItems,
Properties: transformedProperties,
AdditionalProperties: transformedAdditionalProperties,
},
})
}

return &res, nil
}

Expand Down Expand Up @@ -134,7 +234,7 @@ func (c commentTags) Validate() error {
if c.Minimum != nil && c.Maximum != nil && *c.Minimum > *c.Maximum {
err = errors.Join(err, fmt.Errorf("minimum %f is greater than maximum %f", *c.Minimum, *c.Maximum))
}
if (c.ExclusiveMinimum || c.ExclusiveMaximum) && c.Minimum != nil && c.Maximum != nil && *c.Minimum == *c.Maximum {
if (c.ExclusiveMinimum != nil || c.ExclusiveMaximum != nil) && c.Minimum != nil && c.Maximum != nil && *c.Minimum == *c.Maximum {
err = errors.Join(err, fmt.Errorf("exclusiveMinimum/Maximum cannot be set when minimum == maximum"))
}
if c.MinLength != nil && c.MaxLength != nil && *c.MinLength > *c.MaxLength {
Expand All @@ -146,10 +246,10 @@ func (c commentTags) Validate() error {
if c.MinProperties != nil && c.MaxProperties != nil && *c.MinProperties > *c.MaxProperties {
err = errors.Join(err, fmt.Errorf("minProperties %d is greater than maxProperties %d", *c.MinProperties, *c.MaxProperties))
}
if c.Pattern != "" {
_, e := regexp.Compile(c.Pattern)
if c.Pattern != nil {
_, e := regexp.Compile(*c.Pattern)
if e != nil {
err = errors.Join(err, fmt.Errorf("invalid pattern %q: %v", c.Pattern, e))
err = errors.Join(err, fmt.Errorf("invalid pattern %q: %v", *c.Pattern, e))
}
}
if c.MultipleOf != nil && *c.MultipleOf == 0 {
Expand All @@ -175,31 +275,45 @@ func (c commentTags) ValidateType(t *types.Type) error {
typeString, _ := openapi.OpenAPITypeFormat(resolvedType.String()) // will be empty for complicated types

// Structs and interfaces may dynamically be any type, so we cant validate them
// easily. We may be able to if we check that they don't implement all the
// override functions, but for now we just skip them.
// easily.
if resolvedType.Kind == types.Interface || resolvedType.Kind == types.Struct {
return nil
// Skip validation for structs and interfaces which implement custom
// overrides
//
// Only check top-level t type without resolving alias to mirror generator
// behavior. Generator only checks the top level type without resolving
// alias. The `has*Method` functions can be changed to add this behavior in the
// future if needed.
elemT := resolvePtrType(t)
if hasOpenAPIDefinitionMethod(elemT) ||
hasOpenAPIDefinitionMethods(elemT) ||
hasOpenAPIV3DefinitionMethod(elemT) ||
hasOpenAPIV3OneOfMethod(elemT) {

return nil
}
}

isArray := resolvedType.Kind == types.Slice || resolvedType.Kind == types.Array
isMap := resolvedType.Kind == types.Map
isString := typeString == "string"
isInt := typeString == "integer"
isFloat := typeString == "number"
isStruct := resolvedType.Kind == types.Struct

if c.MaxItems != nil && !isArray {
err = errors.Join(err, fmt.Errorf("maxItems can only be used on array types"))
}
if c.MinItems != nil && !isArray {
err = errors.Join(err, fmt.Errorf("minItems can only be used on array types"))
}
if c.UniqueItems && !isArray {
if c.UniqueItems != nil && !isArray {
err = errors.Join(err, fmt.Errorf("uniqueItems can only be used on array types"))
}
if c.MaxProperties != nil && !isMap {
if c.MaxProperties != nil && !(isMap || isStruct) {
err = errors.Join(err, fmt.Errorf("maxProperties can only be used on map types"))
}
if c.MinProperties != nil && !isMap {
if c.MinProperties != nil && !(isMap || isStruct) {
err = errors.Join(err, fmt.Errorf("minProperties can only be used on map types"))
}
if c.MinLength != nil && !isString {
Expand All @@ -208,7 +322,7 @@ func (c commentTags) ValidateType(t *types.Type) error {
if c.MaxLength != nil && !isString {
err = errors.Join(err, fmt.Errorf("maxLength can only be used on string types"))
}
if c.Pattern != "" && !isString {
if c.Pattern != nil && !isString {
err = errors.Join(err, fmt.Errorf("pattern can only be used on string types"))
}
if c.Minimum != nil && !isInt && !isFloat {
Expand All @@ -220,16 +334,57 @@ func (c commentTags) ValidateType(t *types.Type) error {
if c.MultipleOf != nil && !isInt && !isFloat {
err = errors.Join(err, fmt.Errorf("multipleOf can only be used on numeric types"))
}
if c.ExclusiveMinimum && !isInt && !isFloat {
if c.ExclusiveMinimum != nil && !isInt && !isFloat {
err = errors.Join(err, fmt.Errorf("exclusiveMinimum can only be used on numeric types"))
}
if c.ExclusiveMaximum && !isInt && !isFloat {
if c.ExclusiveMaximum != nil && !isInt && !isFloat {
err = errors.Join(err, fmt.Errorf("exclusiveMaximum can only be used on numeric types"))
}
if c.AdditionalProperties != nil && !isMap {
err = errors.Join(err, fmt.Errorf("additionalProperties can only be used on map types"))

if err == nil {
err = errors.Join(err, c.AdditionalProperties.ValidateType(t))
}
}
if c.Items != nil && !isArray {
err = errors.Join(err, fmt.Errorf("items can only be used on array types"))

if err == nil {
err = errors.Join(err, c.Items.ValidateType(t))
}
}
if c.Properties != nil {
if !isStruct && !isMap {
err = errors.Join(err, fmt.Errorf("properties can only be used on struct types"))
} else if isStruct && err == nil {
for key, tags := range c.Properties {
if member := memberWithJSONName(resolvedType, key); member == nil {
err = errors.Join(err, fmt.Errorf("property used in comment tag %q not found in struct %s", key, resolvedType.String()))
} else if nestedErr := tags.ValidateType(member.Type); nestedErr != nil {
err = errors.Join(err, fmt.Errorf("failed to validate property %q: %w", key, nestedErr))
}
}
}
}

return err
}

func memberWithJSONName(t *types.Type, key string) *types.Member {
for _, member := range t.Members {
tags := getJsonTags(&member)
if len(tags) > 0 && tags[0] == key {
return &member
} else if member.Embedded {
if embeddedMember := memberWithJSONName(member.Type, key); embeddedMember != nil {
return embeddedMember
}
}
}
return nil
}

// Parses the given comments into a CommentTags type. Validates the parsed comment tags, and returns the result.
// Accepts an optional type to validate against, and a prefix to filter out markers not related to validation.
// Accepts a prefix to filter out markers not related to validation.
Expand Down
Loading

0 comments on commit 631461f

Please sign in to comment.