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
17 changes: 15 additions & 2 deletions gen/constrain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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
Comment on lines +45 to +55

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

}
return v
}
Expand Down
43 changes: 35 additions & 8 deletions gen/gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions gen/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 33 additions & 1 deletion gen/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package gen
import (
"go/ast"
"maps"
"reflect"
"regexp"
"slices"
"strconv"
Expand Down Expand Up @@ -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["):
Expand Down Expand Up @@ -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)
Comment on lines 327 to +331

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

}
}

Expand Down Expand Up @@ -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
}
Comment on lines +654 to +657

func makeNullable(s *Schema) *Schema {
if s == nil {
return &Schema{Type: []string{"null"}}
Expand Down
21 changes: 17 additions & 4 deletions gen/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +34 to 37
return []byte(b.String()), nil
}
Expand All @@ -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))
Expand All @@ -73,16 +76,26 @@ 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
b.WriteByte(' ')
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)
}
}
Expand Down
6 changes: 5 additions & 1 deletion testdata/sample.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading