Skip to content
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Thanks for your interest in improving `openapi-parser`. When contributing to thi

## Development setup

You need Go 1.26 or later.
You need Go 1.27 or later.

```sh
git clone https://github.com/indykite/openapi-parser
Expand Down
44 changes: 41 additions & 3 deletions gen/constrain.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,52 @@ func enumTarget(s *Schema) *Schema {
return s
}

// isRequiredRule reports whether a validator rule list makes the field itself
// mandatory. Only rules before the first `dive` apply to the field: everything
// after it constrains the elements (or, between `keys` and `endkeys`, the map
// keys), so `omitempty,dive,keys,required,endkeys,required` on a map means
// "if present, every key and value must be non-empty", not "field required".
func isRequiredRule(rules string) bool {
for rule := range strings.SplitSeq(rules, ",") {
switch strings.TrimSpace(rule) {
case "dive":
return false
case "required":
return true
}
}
return false
}

// applyValidationRules maps the widely-used go-playground/validator rules to
// schema constraints. `required` is handled by the caller (it belongs to the
// parent object); unknown/custom validators are ignored. `dive` redirects the
// remaining rules to the element schema, matching the validator's semantics
// (`min=1,dive,min=8` = at least one element, each at least 8 long).
// (`min=1,dive,min=8` = at least one element, each at least 8 long); on a map
// the elements are the values (additionalProperties). Rules between `keys`
// and `endkeys` constrain map keys and are skipped.
func applyValidationRules(s *Schema, rules string) {
if s == nil || rules == "" {
return
}
inKeys := false
for rule := range strings.SplitSeq(rules, ",") {
name, val, _ := strings.Cut(strings.TrimSpace(rule), "=")
if name == "dive" {
if s = s.Items; s == nil {
switch name {
case "dive":
if s = elementSchema(s); s == nil {
return
}
continue
case "keys":
inKeys = true
continue
case "endkeys":
inKeys = false
continue
}
if inKeys {
continue
}
switch name {
case "oneof":
Expand Down Expand Up @@ -126,6 +156,14 @@ func applyValidationRules(s *Schema, rules string) {
}
}

// elementSchema is what `dive` descends into: array items, or map values.
func elementSchema(s *Schema) *Schema {
if s.Items != nil {
return s.Items
}
return s.AdditionalProperties
}

type sizeBound bool

const (
Expand Down
14 changes: 14 additions & 0 deletions gen/gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,20 @@ func TestEscapedTagsUnexportedDiveRequired(t *testing.T) {
t.Errorf("dive should put min=8 on items.minLength: %+v", hosts.Items)
}

// `required` after dive (and inside keys/endkeys) constrains map keys and
// values, not the field: params stays optional.
if slices.Contains(acct.Required, "params") {
t.Errorf("params must not be required, got %v", acct.Required)
}
params := acct.Properties["params"]
values := params.AdditionalProperties
if values == nil || values.MaxLength == nil || *values.MaxLength != 64 {
t.Errorf("dive on a map should put max=64 on additionalProperties.maxLength: %+v", values)
}
if params.MaxLength != nil || params.MinLength != nil {
t.Errorf("map itself must carry no string constraints: %+v", params)
}

// unexported fields are never marshaled.
if _, ok := acct.Properties["hidden"]; ok {
t.Error("unexported field must not appear in the schema")
Expand Down
25 changes: 25 additions & 0 deletions gen/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,31 @@ func TestApplyValidationRulesTable(t *testing.T) {
}
applyValidationRules(&Schema{Type: []string{"array"}}, "dive,min=1") // nil items: no panic

// dive on a map descends into additionalProperties; keys..endkeys rules
// constrain map keys and must not leak onto the values.
m := &Schema{Type: []string{"object"}, AdditionalProperties: &Schema{Type: []string{"string"}}}
applyValidationRules(m, "omitempty,dive,keys,required,max=3,endkeys,required,max=64")
if m.MaxLength != nil || m.AdditionalProperties.MaxLength == nil || *m.AdditionalProperties.MaxLength != 64 {
t.Errorf("map dive rules: %+v values %+v", m, m.AdditionalProperties)
}
applyValidationRules(&Schema{Type: []string{"object"}}, "dive,keys,required,endkeys,min=1") // nil values: no panic

// required only counts before dive: after it, it applies to elements.
for rules, want := range map[string]bool{
"required": true,
"required,dive,min=1": true,
" required , max=3": true,
"omitempty,dive,required": false,
"omitempty,dive,keys,required,endkeys,required": false,
"min=1,dive,required": false,
"": false,
"required_if=Other x": false,
} {
if got := isRequiredRule(rules); got != want {
t.Errorf("isRequiredRule(%q) = %v, want %v", rules, got, want)
}
}

// custom validators must be ignored without panicking
ignored := &Schema{Type: []string{"string"}}
applyValidationRules(ignored, "required,gid=PROJECT,node_type,omitempty")
Expand Down
6 changes: 4 additions & 2 deletions gen/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,10 @@ func (r *resolver) addField(schema *Schema, field *ast.Field, ctx refCtx, subst
schema.Properties[name] = fieldSchema

// like swag, `required` wins even over json omitempty - a request field
// can be mandatory while the response marshaler omits empty values
if hasTag(tag, "validate", "required") || hasTag(tag, "binding", "required") {
// can be mandatory while the response marshaler omits empty values.
// Only a top-level `required` counts: one after `dive` (or inside
// keys/endkeys) constrains the elements, not the field.
if isRequiredRule(tagValue(tag, "validate")) || isRequiredRule(tagValue(tag, "binding")) {
schema.Required = append(schema.Required, name)
// A required pointer field rejects JSON null at validation time,
// so drop the null branch the pointer type added.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/indykite/openapi-parser

go 1.26.4
go 1.27.1

Comment thread
cowan-macady marked this conversation as resolved.
// Stdlib only by design. Parsing uses go/parser + go/ast; emission uses
// encoding/json plus a minimal internal YAML encoder (gen/yaml.go). No
Expand Down
4 changes: 4 additions & 0 deletions testdata/sample.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ type Account struct {
Code string `json:"code,omitempty" validate:"required"`
// Hosts: validator dive scopes min=8 to each element, not the array.
Hosts []string `json:"hosts" binding:"min=1,dive,min=8"`
// Params: optional map whose keys and values, when present, must be
// non-empty. The `required` rules after dive must not make the field
// itself required; max=64 applies to each value.
Params map[string]string `json:"params" binding:"omitempty,dive,keys,required,endkeys,required,max=64"`
// Contact is a required pointer: validation rejects null, so no null type.
Contact *string `json:"contact" validate:"required"`
// Parent account, required: the null branch is dropped, this doc survives.
Expand Down
Loading