From 78cc70218c246109f8157dc610bf565098f69551 Mon Sep 17 00:00:00 2001 From: Sean O'Grady <1761115+seanogdev@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:01:07 +0100 Subject: [PATCH 1/4] Describe map-of-slice values via additionalProperties The `*ast.MapType` handler only resolved map values of kind `*ast.Ident`/`*ast.SelectorExpr`, so fields typed as `map[K][]T` silently dropped the element type and rendered as `type: object` with no `additionalProperties`. Consumers (openapi-typescript, etc.) then saw an opaque `Record` where the server actually emits a map of arrays. Detect a slice value up front and build an array schema for it via `resolveArray`, attaching it as `additionalProperties`. While here, make `prefixPropertyReferences` recurse into nested `additionalProperties` and `items` so references inside the new array schema get the `#/definitions/...` prefix they need. Fixture `struct-map` grows three new cases covering primitive, local, and cross-package slice element types. --- docparse/jsonschema.go | 14 ++++++++++ openapi2/openapi2.go | 31 +++++++++++++--------- testdata/openapi2/src/struct-map/in.go | 13 +++++---- testdata/openapi2/src/struct-map/want.yaml | 21 +++++++++++++++ 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/docparse/jsonschema.go b/docparse/jsonschema.go index 9c7d45a..4019f11 100644 --- a/docparse/jsonschema.go +++ b/docparse/jsonschema.go @@ -433,6 +433,20 @@ start: // As far as I can find there is no obvious/elegant way to represent // this in JSON schema, so it's just an object. p.Type = "object" + + // Map value is a slice (e.g. `map[K][]T`): describe it as an array + // schema and attach it via `additionalProperties`. Without this the + // item type is dropped entirely and the map renders as an opaque + // `type: object`. + if arr, ok := dropTypePointers(typ.Value).(*ast.ArrayType); ok { + items := &Schema{Type: "array"} + if err := resolveArray(prog, ref, pkg, items, arr.Elt, false, generics); err != nil { + return nil, fmt.Errorf("MapType resolveArray: %v", err) + } + p.AdditionalProperties = items + return &p, nil + } + vtyp, vpkg, err := findTypeIdent(typ.Value, pkg) if err != nil { // we cannot find a mapping to a concrete type, diff --git a/openapi2/openapi2.go b/openapi2/openapi2.go index 2ab0d08..ac3b30e 100644 --- a/openapi2/openapi2.go +++ b/openapi2/openapi2.go @@ -514,26 +514,31 @@ func appendIfNotExists(xs []string, y string) []string { func prefixPropertyReferences(properties map[string]*docparse.Schema, getRef func(string) string) { var rm []string for k, s := range properties { - if s.Reference != "" { - s.Reference = getRef(s.Reference) - } - if s.Items != nil && s.Items.Reference != "" { - s.Items.Reference = getRef(s.Items.Reference) - } - if s.AdditionalProperties != nil && s.AdditionalProperties.Reference != "" { - s.AdditionalProperties.Reference = getRef(s.AdditionalProperties.Reference) - } + prefixSchemaReferences(s, getRef) if s.OmitDoc { rm = append(rm, k) } - - if s.Properties != nil { - prefixPropertyReferences(s.Properties, getRef) - } } for _, r := range rm { delete(properties, r) } } + +// prefixSchemaReferences rewrites all `$ref` strings inside a schema (including +// nested items, additionalProperties, and properties) to their fully-qualified +// `#/definitions/...` form. +func prefixSchemaReferences(s *docparse.Schema, getRef func(string) string) { + if s == nil { + return + } + if s.Reference != "" { + s.Reference = getRef(s.Reference) + } + prefixSchemaReferences(s.Items, getRef) + prefixSchemaReferences(s.AdditionalProperties, getRef) + if s.Properties != nil { + prefixPropertyReferences(s.Properties, getRef) + } +} diff --git a/testdata/openapi2/src/struct-map/in.go b/testdata/openapi2/src/struct-map/in.go index ec3ebd1..ca6ab18 100644 --- a/testdata/openapi2/src/struct-map/in.go +++ b/testdata/openapi2/src/struct-map/in.go @@ -3,11 +3,14 @@ package path import "struct-map/otherpkg" type resp struct { - Basic map[string]interface{} `json:"basic"` // Basic comment. - Basic2 map[string]any `json:"basic2"` // Basic2 comment. - Custom myMap `json:"custom"` // Custom comment. - Struct aStruct `json:"aStruct"` // Struct comment. - OtherStruct otherpkg.OtherStruct `json:"otherStruct"` // OtherStruct comment. + Basic map[string]interface{} `json:"basic"` // Basic comment. + Basic2 map[string]any `json:"basic2"` // Basic2 comment. + Custom myMap `json:"custom"` // Custom comment. + Struct aStruct `json:"aStruct"` // Struct comment. + OtherStruct otherpkg.OtherStruct `json:"otherStruct"` // OtherStruct comment. + PrimSlices map[string][]string `json:"primSlices"` // Map of primitive slices. + StructSlice map[int64][]aStruct `json:"structSlice"` // Map of local struct slices. + OtherSlice map[string][]otherpkg.OtherStruct `json:"otherSlice"` // Map of cross-package struct slices. } // Comments are lost here as its just in the doc as an object. diff --git a/testdata/openapi2/src/struct-map/want.yaml b/testdata/openapi2/src/struct-map/want.yaml index a2ea06a..e5ae16f 100644 --- a/testdata/openapi2/src/struct-map/want.yaml +++ b/testdata/openapi2/src/struct-map/want.yaml @@ -51,5 +51,26 @@ definitions: type: object additionalProperties: type: string + otherSlice: + description: Map of cross-package struct slices. + type: object + additionalProperties: + type: array + items: + $ref: '#/definitions/otherpkg.OtherStruct' otherStruct: $ref: '#/definitions/otherpkg.OtherStruct' + primSlices: + description: Map of primitive slices. + type: object + additionalProperties: + type: array + items: + type: string + structSlice: + description: Map of local struct slices. + type: object + additionalProperties: + type: array + items: + $ref: '#/definitions/struct-map.aStruct' From fb8fd3eb966215a25bc259141c9e8fc8ad8f015f Mon Sep 17 00:00:00 2001 From: Sean O'Grady <1761115+seanogdev@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:22:19 +0100 Subject: [PATCH 2/4] Extract MapType handling into resolveMap helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps fieldToSchema's cyclomatic complexity under the gocyclo threshold (65). Pure refactor — no behavior change, no new tests needed. --- docparse/jsonschema.go | 99 ++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/docparse/jsonschema.go b/docparse/jsonschema.go index 4019f11..5e1ff54 100644 --- a/docparse/jsonschema.go +++ b/docparse/jsonschema.go @@ -430,52 +430,8 @@ start: // Maps case *ast.MapType: - // As far as I can find there is no obvious/elegant way to represent - // this in JSON schema, so it's just an object. - p.Type = "object" - - // Map value is a slice (e.g. `map[K][]T`): describe it as an array - // schema and attach it via `additionalProperties`. Without this the - // item type is dropped entirely and the map renders as an opaque - // `type: object`. - if arr, ok := dropTypePointers(typ.Value).(*ast.ArrayType); ok { - items := &Schema{Type: "array"} - if err := resolveArray(prog, ref, pkg, items, arr.Elt, false, generics); err != nil { - return nil, fmt.Errorf("MapType resolveArray: %v", err) - } - p.AdditionalProperties = items - return &p, nil - } - - vtyp, vpkg, err := findTypeIdent(typ.Value, pkg) - if err != nil { - // we cannot find a mapping to a concrete type, - // so we cannot define the type of the maps -> ? - dbg("ERR FOUND MapType: %s", err.Error()) - return &p, nil - } - if generics != nil && generics[vtyp.Name] != "" { - vtyp.Name = generics[vtyp.Name] - } - if isPrimitive(vtyp.Name) { - // we are done, no need for a lookup of a custom type - if vtyp.Name != "any" { - p.AdditionalProperties = &Schema{Type: JSONSchemaType(vtyp.Name)} - } - return &p, nil - } - - _, lref, err := lookupTypeAndRef(ref.File, vpkg, vtyp.Name) - if err == nil { - // found additional properties - p.AdditionalProperties = &Schema{Reference: lref} - // Make sure the reference is added to `prog.References`: - _, err := GetReference(prog, ref.Context, false, lref, ref.File) - if err != nil { - dbg("ERR, Could not find additionalProperties Reference: %s", err.Error()) - } - } else { - dbg("ERR, Could not find additionalProperties: %s", err.Error()) + if err := resolveMap(prog, ref, pkg, &p, typ, generics); err != nil { + return nil, err } return &p, nil @@ -765,6 +721,57 @@ func lookupTypeAndRef(file, pkg, name string) (string, string, error) { return t, sRef, nil } +// resolveMap fills p with an `object` schema describing a Go map. Where we can +// identify the value type it's attached as `additionalProperties`; otherwise p +// is left as an open object (what Swagger 2 gives us in the absence of better +// information). Slice value types are handled explicitly so that e.g. +// `map[K][]T` doesn't lose its element type. +func resolveMap( + prog *Program, + ref Reference, + pkg string, + p *Schema, + typ *ast.MapType, + generics map[string]string, +) error { + p.Type = "object" + + if arr, ok := dropTypePointers(typ.Value).(*ast.ArrayType); ok { + items := &Schema{Type: "array"} + if err := resolveArray(prog, ref, pkg, items, arr.Elt, false, generics); err != nil { + return fmt.Errorf("resolveMap resolveArray: %v", err) + } + p.AdditionalProperties = items + return nil + } + + vtyp, vpkg, err := findTypeIdent(typ.Value, pkg) + if err != nil { + dbg("ERR FOUND MapType: %s", err.Error()) + return nil + } + if generics != nil && generics[vtyp.Name] != "" { + vtyp.Name = generics[vtyp.Name] + } + if isPrimitive(vtyp.Name) { + if vtyp.Name != "any" { + p.AdditionalProperties = &Schema{Type: JSONSchemaType(vtyp.Name)} + } + return nil + } + + _, lref, err := lookupTypeAndRef(ref.File, vpkg, vtyp.Name) + if err != nil { + dbg("ERR, Could not find additionalProperties: %s", err.Error()) + return nil + } + p.AdditionalProperties = &Schema{Reference: lref} + if _, err := GetReference(prog, ref.Context, false, lref, ref.File); err != nil { + dbg("ERR, Could not find additionalProperties Reference: %s", err.Error()) + } + return nil +} + func resolveArray( prog *Program, ref Reference, From 46c5f0d40c7d28fb9d5001406b774c562781c54d Mon Sep 17 00:00:00 2001 From: Sean O'Grady <1761115+seanogdev@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:27:11 +0100 Subject: [PATCH 3/4] Recurse into nested map values in resolveMap A `map[K]map[K2]V` field previously dropped the inner map's value type: `findTypeIdent` only handles `Ident`/`SelectorExpr`, so a map-typed value fell through to the open-object fallback. Detect `*ast.MapType` up front and recurse, producing nested `additionalProperties`. `prefixSchemaReferences` already walks `AdditionalProperties`, so `$ref`s inside the nested schema are rewritten correctly without further changes. --- docparse/jsonschema.go | 12 ++++++++++-- testdata/openapi2/src/struct-map/in.go | 7 ++++--- testdata/openapi2/src/struct-map/want.yaml | 11 +++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docparse/jsonschema.go b/docparse/jsonschema.go index 5e1ff54..12520b2 100644 --- a/docparse/jsonschema.go +++ b/docparse/jsonschema.go @@ -736,13 +736,21 @@ func resolveMap( ) error { p.Type = "object" - if arr, ok := dropTypePointers(typ.Value).(*ast.ArrayType); ok { + switch v := dropTypePointers(typ.Value).(type) { + case *ast.ArrayType: items := &Schema{Type: "array"} - if err := resolveArray(prog, ref, pkg, items, arr.Elt, false, generics); err != nil { + if err := resolveArray(prog, ref, pkg, items, v.Elt, false, generics); err != nil { return fmt.Errorf("resolveMap resolveArray: %v", err) } p.AdditionalProperties = items return nil + case *ast.MapType: + inner := &Schema{} + if err := resolveMap(prog, ref, pkg, inner, v, generics); err != nil { + return fmt.Errorf("resolveMap nested: %v", err) + } + p.AdditionalProperties = inner + return nil } vtyp, vpkg, err := findTypeIdent(typ.Value, pkg) diff --git a/testdata/openapi2/src/struct-map/in.go b/testdata/openapi2/src/struct-map/in.go index ca6ab18..7217153 100644 --- a/testdata/openapi2/src/struct-map/in.go +++ b/testdata/openapi2/src/struct-map/in.go @@ -8,9 +8,10 @@ type resp struct { Custom myMap `json:"custom"` // Custom comment. Struct aStruct `json:"aStruct"` // Struct comment. OtherStruct otherpkg.OtherStruct `json:"otherStruct"` // OtherStruct comment. - PrimSlices map[string][]string `json:"primSlices"` // Map of primitive slices. - StructSlice map[int64][]aStruct `json:"structSlice"` // Map of local struct slices. - OtherSlice map[string][]otherpkg.OtherStruct `json:"otherSlice"` // Map of cross-package struct slices. + PrimSlices map[string][]string `json:"primSlices"` // Map of primitive slices. + StructSlice map[int64][]aStruct `json:"structSlice"` // Map of local struct slices. + OtherSlice map[string][]otherpkg.OtherStruct `json:"otherSlice"` // Map of cross-package struct slices. + NestedMap map[string]map[string]otherpkg.OtherStruct `json:"nestedMap"` // Nested map of cross-package structs. } // Comments are lost here as its just in the doc as an object. diff --git a/testdata/openapi2/src/struct-map/want.yaml b/testdata/openapi2/src/struct-map/want.yaml index e5ae16f..0e53787 100644 --- a/testdata/openapi2/src/struct-map/want.yaml +++ b/testdata/openapi2/src/struct-map/want.yaml @@ -26,12 +26,16 @@ definitions: map: description: Map contains some random data :) type: object + additionalProperties: + type: object struct-map.aStruct: title: aStruct type: object properties: bar: type: object + additionalProperties: + type: object foo: type: string struct-map.resp: @@ -51,6 +55,13 @@ definitions: type: object additionalProperties: type: string + nestedMap: + description: Nested map of cross-package structs. + type: object + additionalProperties: + type: object + additionalProperties: + $ref: '#/definitions/otherpkg.OtherStruct' otherSlice: description: Map of cross-package struct slices. type: object From 4c622b1e55f1a68b443be64dbec4f40f900c22a6 Mon Sep 17 00:00:00 2001 From: Sean O'Grady <1761115+seanogdev@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:27:38 +0100 Subject: [PATCH 4/4] Normalise map value type before primitive check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveMap` compared the raw Go type name against the JSON-schema primitive set, so `int`/`bool`/`float64` failed the check and fell through to an open-object fallback. Run `JSONSchemaType` first so `int`→`integer`, `bool`→`boolean`, etc. match. --- docparse/jsonschema.go | 2 +- testdata/openapi2/src/struct-map/in.go | 1 + testdata/openapi2/src/struct-map/want.yaml | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docparse/jsonschema.go b/docparse/jsonschema.go index 12520b2..77f263d 100644 --- a/docparse/jsonschema.go +++ b/docparse/jsonschema.go @@ -761,7 +761,7 @@ func resolveMap( if generics != nil && generics[vtyp.Name] != "" { vtyp.Name = generics[vtyp.Name] } - if isPrimitive(vtyp.Name) { + if isPrimitive(JSONSchemaType(vtyp.Name)) { if vtyp.Name != "any" { p.AdditionalProperties = &Schema{Type: JSONSchemaType(vtyp.Name)} } diff --git a/testdata/openapi2/src/struct-map/in.go b/testdata/openapi2/src/struct-map/in.go index 7217153..05e5576 100644 --- a/testdata/openapi2/src/struct-map/in.go +++ b/testdata/openapi2/src/struct-map/in.go @@ -11,6 +11,7 @@ type resp struct { PrimSlices map[string][]string `json:"primSlices"` // Map of primitive slices. StructSlice map[int64][]aStruct `json:"structSlice"` // Map of local struct slices. OtherSlice map[string][]otherpkg.OtherStruct `json:"otherSlice"` // Map of cross-package struct slices. + NestedPrim map[string]map[string]int `json:"nestedPrim"` // Nested map of primitives. NestedMap map[string]map[string]otherpkg.OtherStruct `json:"nestedMap"` // Nested map of cross-package structs. } diff --git a/testdata/openapi2/src/struct-map/want.yaml b/testdata/openapi2/src/struct-map/want.yaml index 0e53787..095e8fe 100644 --- a/testdata/openapi2/src/struct-map/want.yaml +++ b/testdata/openapi2/src/struct-map/want.yaml @@ -62,6 +62,13 @@ definitions: type: object additionalProperties: $ref: '#/definitions/otherpkg.OtherStruct' + nestedPrim: + description: Nested map of primitives. + type: object + additionalProperties: + type: object + additionalProperties: + type: integer otherSlice: description: Map of cross-package struct slices. type: object