diff --git a/gen/constrain.go b/gen/constrain.go index 21e4522..0e7c265 100644 --- a/gen/constrain.go +++ b/gen/constrain.go @@ -24,8 +24,10 @@ import ( ) // coerceScalar converts a raw annotation/tag string to the schema's scalar -// type, so `example:"5"` on an int field emits 5, not "5". Unparseable -// values stay strings. +// type, so `example:"5"` on an int field emits 5, not "5". On array schemas +// the value is comma-separated per swag semantics (`example:"a,b"` on a +// []string field emits ["a", "b"]), each item coerced to the item type. +// Unparseable values stay strings. func coerceScalar(v string, s *Schema) any { switch primaryType(s) { case "integer": @@ -40,6 +42,17 @@ func coerceScalar(v string, s *Schema) any { if b, err := strconv.ParseBool(v); err == nil { return b } + case "array": + item := s.Items + if item == nil { + item = &Schema{} + } + parts := strings.Split(v, ",") + arr := make([]any, 0, len(parts)) + for _, p := range parts { + arr = append(arr, coerceScalar(strings.TrimSpace(p), item)) + } + return arr } return v } diff --git a/gen/gen_test.go b/gen/gen_test.go index 48a3110..9b7ed2e 100644 --- a/gen/gen_test.go +++ b/gen/gen_test.go @@ -225,20 +225,18 @@ func TestEmbeddedStructPromotion(t *testing.T) { } // Named non-struct type (type Labels []*Account) resolves inline; the - // pointer element makes items nullable via anyOf ($ref allows no siblings). + // pointer element is a Go implementation detail, so items stay a plain + // non-nullable $ref (a JSON null element is never valid input). labels := acct.Properties["labels"] if labels == nil || !slices.Contains(labels.Type, "array") { t.Fatalf("labels should inline the named slice type as array, got %+v", labels) } items := labels.Items - if items == nil || len(items.AnyOf) != 2 { - t.Fatalf("pointer items should be anyOf[$ref, null], got %+v", items) + if items == nil || len(items.AnyOf) != 0 { + t.Fatalf("pointer items should be a plain non-nullable ref, got %+v", items) } - if items.AnyOf[0].Ref != "#/components/schemas/testdata.Account" { - t.Errorf("anyOf[0] should ref Account, got %+v", items.AnyOf[0]) - } - if !slices.Contains(items.AnyOf[1].Type, "null") { - t.Errorf("anyOf[1] should be null, got %+v", items.AnyOf[1]) + if items.Ref != "#/components/schemas/testdata.Account" { + t.Errorf("items should ref Account, got %+v", items) } } @@ -334,6 +332,35 @@ func TestEscapedTagsUnexportedDiveRequired(t *testing.T) { } } +func TestRequiredPointerNotNullable(t *testing.T) { + api, err := gen.Parse([]string{"../testdata"}) + if err != nil { + t.Fatal(err) + } + acct := api.Schemas["testdata.Account"] + for _, want := range []string{"contact", "parent"} { + if !slices.Contains(acct.Required, want) { + t.Errorf("%s should be required, got %v", want, acct.Required) + } + } + + // A required *string rejects null at validation time: plain string type. + contact := acct.Properties["contact"] + if !slices.Contains(contact.Type, "string") || slices.Contains(contact.Type, "null") { + t.Errorf("required *string must not be nullable: %+v", contact) + } + + // A required pointer-to-struct drops the anyOf null branch but keeps the + // field metadata applied to the wrapper (here, the doc comment). + parent := acct.Properties["parent"] + if len(parent.AnyOf) != 1 || parent.AnyOf[0].Ref != "#/components/schemas/testdata.Account" { + t.Errorf("required pointer ref should keep only the ref branch: %+v", parent) + } + if !strings.Contains(parent.Description, "doc survives") { + t.Errorf("wrapper description lost: %+v", parent) + } +} + func TestQuotedDescriptionWithParens(t *testing.T) { api, err := gen.Parse([]string{"../testdata"}) if err != nil { diff --git a/gen/internal_test.go b/gen/internal_test.go index 85eca14..398322e 100644 --- a/gen/internal_test.go +++ b/gen/internal_test.go @@ -93,6 +93,27 @@ func TestCoerceScalarTable(t *testing.T) { } } +func TestCoerceScalarArrayTable(t *testing.T) { + strArr := &Schema{Type: []string{"array"}, Items: &Schema{Type: []string{"string"}}} + intArr := &Schema{Type: []string{"array"}, Items: &Schema{Type: []string{"integer"}}} + cases := []struct { + schema *Schema + want any + in string + }{ + {strArr, []any{"a", "b"}, "a,b"}, + {strArr, []any{"a"}, "a"}, // single item, no comma + {intArr, []any{int64(1), int64(2)}, "1, 2"}, // items trimmed and coerced + {intArr, []any{int64(1), "2x"}, "1,2x"}, // unparseable item stays string + {&Schema{Type: []string{"array"}}, []any{"a", "b"}, "a,b"}, // nil Items: items stay strings + } + for _, c := range cases { + if got := coerceScalar(c.in, c.schema); !reflect.DeepEqual(got, c.want) { + t.Errorf("coerceScalar(%q, %v) = %#v, want %#v", c.in, c.schema, got, c.want) + } + } +} + func TestApplyValidationRulesTable(t *testing.T) { str := &Schema{Type: []string{"string"}} applyValidationRules(str, "min=3,max=10,len=4") @@ -304,6 +325,35 @@ func TestMakeNullableTable(t *testing.T) { } } +func TestStripNullableTable(t *testing.T) { + if stripNullable(nil) != nil { + t.Error("nil should stay nil") + } + prim := stripNullable(&Schema{Type: []string{"string", "null"}}) + if !reflect.DeepEqual(prim.Type, []string{"string"}) { + t.Errorf("primitive: %+v", prim) + } + bare := stripNullable(makeNullable(&Schema{Ref: "#/components/schemas/X"})) + if bare.Ref != "#/components/schemas/X" || len(bare.AnyOf) != 0 { + t.Errorf("bare wrapper should collapse to the inner ref: %+v", bare) + } + rich := makeNullable(&Schema{Ref: "#/components/schemas/X"}) + rich.Description = "doc" + got := stripNullable(rich) + if got.Description != "doc" || len(got.AnyOf) != 1 || got.AnyOf[0].Ref == "" { + t.Errorf("wrapper metadata should survive minus the null branch: %+v", got) + } +} + +func TestArrayItemsMultiPointerNotNullable(t *testing.T) { + for _, tok := range []string{"[]*int", "[]**int", "[]***int"} { + s := (&resolver{}).schemaForToken(tok, refCtx{}) + if s.Items == nil || !reflect.DeepEqual(s.Items.Type, []string{"integer"}) { + t.Errorf("%s items should be plain integer: %+v", tok, s.Items) + } + } +} + func TestExprToTokenTable(t *testing.T) { d := ast.NewIdent("D") cases := []struct { diff --git a/gen/schema.go b/gen/schema.go index 62679a9..8f6abc7 100644 --- a/gen/schema.go +++ b/gen/schema.go @@ -17,6 +17,7 @@ package gen import ( "go/ast" "maps" + "reflect" "regexp" "slices" "strconv" @@ -53,7 +54,9 @@ func (r *resolver) schemaForToken(tok string, ctx refCtx) *Schema { case tok == "interface{}" || tok == "any": return &Schema{} // empty schema matches anything case strings.HasPrefix(tok, "[]"): - return &Schema{Type: []string{"array"}, Items: r.schemaForToken(tok[2:], ctx)} + // Pointer elements ([]*T) are a Go implementation detail; a JSON null + // element is never valid input, so items are emitted non-nullable. + return &Schema{Type: []string{"array"}, Items: r.schemaForToken(strings.TrimLeft(tok[2:], "*"), ctx)} case strings.HasPrefix(tok, "*"): return makeNullable(r.schemaForToken(tok[1:], ctx)) case strings.HasPrefix(tok, "map["): @@ -323,6 +326,9 @@ func (r *resolver) addField(schema *Schema, field *ast.Field, ctx refCtx, subst // can be mandatory while the response marshaler omits empty values if hasTag(tag, "validate", "required") || hasTag(tag, "binding", "required") { 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. + schema.Properties[name] = stripNullable(fieldSchema) } } @@ -624,6 +630,32 @@ func exprToToken(e ast.Expr) string { } } +// stripNullable undoes makeNullable for schemas where JSON null is not +// actually accepted (e.g. required pointer fields). +func stripNullable(s *Schema) *Schema { + if s == nil { + return nil + } + if len(s.AnyOf) == 2 && isPureNull(s.AnyOf[1]) { + if reflect.DeepEqual(s, &Schema{AnyOf: s.AnyOf}) { + return s.AnyOf[0] // bare wrapper: collapse to the inner schema + } + // Field metadata (description, example, ...) lives on the wrapper; + // keep it and drop only the null branch. + s.AnyOf = s.AnyOf[:1] + return s + } + s.Type = slices.DeleteFunc(s.Type, func(t string) bool { return t == "null" }) + return s +} + +// isPureNull reports whether s is exactly the `{type: "null"}` schema that +// makeNullable produces. +func isPureNull(s *Schema) bool { + return s != nil && len(s.Type) == 1 && s.Type[0] == "null" && + s.Ref == "" && len(s.Properties) == 0 && len(s.AnyOf) == 0 +} + func makeNullable(s *Schema) *Schema { if s == nil { return &Schema{Type: []string{"null"}} diff --git a/gen/yaml.go b/gen/yaml.go index 44fc01e..f0872f3 100644 --- a/gen/yaml.go +++ b/gen/yaml.go @@ -31,6 +31,9 @@ import ( // so the output is always valid YAML. func (api *API) EmitYAML(opt EmitOptions) ([]byte, error) { var b strings.Builder + // Document start marker, so the output survives yamlfmt-style formatters + // (include_document_start) without churn on regeneration. + b.WriteString("---\n") writeYAMLMap(&b, api.Emit(opt), 0, false) return []byte(b.String()), nil } @@ -52,7 +55,7 @@ func writeYAMLValue(b *strings.Builder, v any, indent int) { return } b.WriteByte('\n') - writeYAMLSeq(b, t, indent) + writeYAMLSeq(b, t, indent, false) default: b.WriteByte(' ') b.WriteString(yamlScalar(t)) @@ -73,9 +76,13 @@ func writeYAMLMap(b *strings.Builder, m map[string]any, indent int, inline bool) } } -func writeYAMLSeq(b *strings.Builder, s []any, indent int) { - for _, v := range s { - b.WriteString(strings.Repeat(" ", indent)) +// writeYAMLSeq writes a sequence's items at the given indent. When inline is +// true the first item continues the current line (after an outer dash). +func writeYAMLSeq(b *strings.Builder, s []any, indent int, inline bool) { + for i, v := range s { + if !inline || i > 0 { + b.WriteString(strings.Repeat(" ", indent)) + } b.WriteByte('-') if m, ok := v.(map[string]any); ok && len(m) > 0 { // compact form: first key shares the dash's line @@ -83,6 +90,12 @@ func writeYAMLSeq(b *strings.Builder, s []any, indent int) { writeYAMLMap(b, m, indent+1, true) continue } + if l, ok := v.([]any); ok && len(l) > 0 { + // compact form: nested sequence's first dash shares this line + b.WriteByte(' ') + writeYAMLSeq(b, l, indent+1, true) + continue + } writeYAMLValue(b, v, indent+1) } } diff --git a/testdata/sample.go b/testdata/sample.go index e7cff57..13c2e65 100644 --- a/testdata/sample.go +++ b/testdata/sample.go @@ -70,7 +70,11 @@ type Account struct { // Code is required even though json omits it when empty (swag semantics). 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"` + Hosts []string `json:"hosts" binding:"min=1,dive,min=8"` + // 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. + Parent *Account `json:"parent" validate:"required"` hidden string // unexported: never marshaled, must not appear }