← back to Cli Printing Press
fix(cli): normalize object-shaped description fields before parsing (#285)
6361826d8414debc288f84cb25d7d74f8ad2d90b · 2026-04-25 11:28:53 -0700 · Trevin Chow
Files touched
A internal/openapi/description_normalize.goA internal/openapi/description_normalize_test.goM internal/openapi/parser.go
Diff
commit 6361826d8414debc288f84cb25d7d74f8ad2d90b
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 25 11:28:53 2026 -0700
fix(cli): normalize object-shaped description fields before parsing (#285)
---
internal/openapi/description_normalize.go | 121 ++++++++++++++++++++
internal/openapi/description_normalize_test.go | 148 +++++++++++++++++++++++++
internal/openapi/parser.go | 3 +
3 files changed, 272 insertions(+)
diff --git a/internal/openapi/description_normalize.go b/internal/openapi/description_normalize.go
new file mode 100644
index 00000000..dcd148c1
--- /dev/null
+++ b/internal/openapi/description_normalize.go
@@ -0,0 +1,121 @@
+package openapi
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "gopkg.in/yaml.v3"
+)
+
+// normalizeSpecData parses raw OpenAPI spec bytes (JSON or YAML), rewrites any
+// non-scalar `description` field to an empty string, and re-emits the result as
+// JSON. Returning JSON consistently lets kin-openapi's JSON-first unmarshal
+// path succeed without falling through to its YAML fallback, which collapses
+// the `failed to unmarshal data: json error: ..., yaml error: ...` combined
+// error messages into single-line errors when something further downstream
+// fails.
+//
+// The flatten step exists because some vendors (DigitalOcean's public spec is
+// the canonical example) emit `description: { $ref: "description.yml#/foo" }`
+// pointing at an external markdown bundle instead of the inline string the
+// OpenAPI 3.0 specification mandates. kin-openapi's `Tag.Description`,
+// `Info.Description`, and similar fields are typed as `string`, so the
+// non-string value triggers `cannot unmarshal object into field X of type
+// string` and the spec refuses to load.
+//
+// Descriptions are documentation, not load-bearing for code generation;
+// dropping the text is the right trade-off for keeping the spec parseable.
+// If the input data is not parseable as YAML or JSON, the function returns an
+// error and callers fall back to the original bytes — which preserves
+// kin-openapi's existing error reporting for genuinely malformed input.
+func normalizeSpecData(data []byte) ([]byte, error) {
+ var root any
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("normalize spec: yaml unmarshal: %w", err)
+ }
+ root = convertToStringKeyed(root)
+ flattenObjectDescriptions(root, "")
+ out, err := json.Marshal(root)
+ if err != nil {
+ return nil, fmt.Errorf("normalize spec: json marshal: %w", err)
+ }
+ return out, nil
+}
+
+// convertToStringKeyed walks a value decoded by gopkg.in/yaml.v3 and rewrites
+// any map[any]any nodes (which YAML produces when keys are non-strings) into
+// map[string]any. JSON marshaling rejects map[any]any, so this conversion
+// is a prerequisite for round-tripping through encoding/json. Non-string keys
+// are coerced via fmt.Sprint, which is acceptable for OpenAPI specs where
+// non-string keys are extremely rare and treated as authoring mistakes.
+func convertToStringKeyed(node any) any {
+ switch v := node.(type) {
+ case map[any]any:
+ out := make(map[string]any, len(v))
+ for key, value := range v {
+ out[fmt.Sprint(key)] = convertToStringKeyed(value)
+ }
+ return out
+ case map[string]any:
+ for key, value := range v {
+ v[key] = convertToStringKeyed(value)
+ }
+ return v
+ case []any:
+ for i, item := range v {
+ v[i] = convertToStringKeyed(item)
+ }
+ return v
+ default:
+ return node
+ }
+}
+
+// flattenObjectDescriptions walks the decoded spec tree and replaces any
+// `description` key whose value is a map or slice with an empty string. Scalar
+// descriptions (the common case) pass through untouched.
+//
+// Skips the flatten when the immediate parent key is one whose children are
+// user-named entries (Schema property names, response codes, pattern regexes,
+// component-section names). In those positions a "description" key is the
+// caller's chosen name for an entry, not the structural OpenAPI documentation
+// field. Stytch has a schema with `properties: { description: { type: string,
+// ... } }` that hits this case; flattening there would replace the entry's
+// schema with an empty string and produce
+// `cannot unmarshal string into field Schema.properties of type openapi3.Schema`.
+func flattenObjectDescriptions(node any, parentKey string) {
+ switch v := node.(type) {
+ case map[string]any:
+ if !isNameKeyedParent(parentKey) {
+ if desc, ok := v["description"]; ok {
+ switch desc.(type) {
+ case map[string]any, []any:
+ v["description"] = ""
+ }
+ }
+ }
+ for key, value := range v {
+ flattenObjectDescriptions(value, key)
+ }
+ case []any:
+ for _, item := range v {
+ flattenObjectDescriptions(item, "")
+ }
+ }
+}
+
+// isNameKeyedParent reports whether children of the given parent key are
+// user-defined names rather than OpenAPI structural fields. Inside these
+// containers a "description" key is the caller's chosen name and must not be
+// rewritten as if it were the structural documentation field. Drawn from the
+// OpenAPI 3.0 spec's section names plus JSON Schema's `properties` family.
+func isNameKeyedParent(parentKey string) bool {
+ switch parentKey {
+ case "properties", "patternProperties", "definitions",
+ "schemas", "parameters", "requestBodies", "responses",
+ "headers", "securitySchemes", "links", "callbacks",
+ "examples", "pathItems", "encoding", "content", "paths":
+ return true
+ }
+ return false
+}
diff --git a/internal/openapi/description_normalize_test.go b/internal/openapi/description_normalize_test.go
new file mode 100644
index 00000000..3110eace
--- /dev/null
+++ b/internal/openapi/description_normalize_test.go
@@ -0,0 +1,148 @@
+package openapi
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestParseHandlesObjectDescription covers issue #275 F-4. DigitalOcean's
+// public OpenAPI spec uses `description: { $ref: "description.yml#/foo" }`
+// at the tag level — pointing the description at an external file — instead
+// of the string the OpenAPI 3.0 spec mandates. kin-openapi's `Tag.Description`
+// is `string`, so the YAML decode fails with
+// `cannot unmarshal object into field Tag.description of type string` and
+// the spec refuses to load.
+//
+// Pre-process the spec data to flatten any non-scalar `description` value
+// to an empty string before kin-openapi sees it. Descriptions are
+// documentation, not load-bearing for code generation; losing the text is
+// the right trade-off for keeping the spec parseable.
+func TestParseHandlesObjectDescription(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`
+openapi: "3.0.0"
+info:
+ title: doapi
+ version: "0.1.0"
+servers:
+ - url: https://api.example.com
+tags:
+ - name: Account
+ description:
+ $ref: "description.yml#/account"
+ - name: Droplets
+ description: Manage compute instances.
+paths:
+ /accounts:
+ get:
+ summary: List accounts
+ responses:
+ '200':
+ description: ok
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err, "spec with object-shaped tag description must parse")
+ assert.Equal(t, "doapi", parsed.Name,
+ "normalization must not corrupt unrelated fields")
+ assert.NotEmpty(t, parsed.Resources,
+ "endpoints under the spec must still be discovered")
+}
+
+// TestParseDoesNotFlattenPropertyNamedDescription guards against the
+// flatten pass mistaking a schema property literally named "description" for
+// the structural OpenAPI description field. Stytch's spec has the shape
+// `properties: { description: { type: string } }`; if the flatten replaced
+// the property's schema with an empty string, kin-openapi would reject the
+// spec with `cannot unmarshal string into field Schema.properties of type
+// openapi3.Schema`.
+func TestParseDoesNotFlattenPropertyNamedDescription(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`
+openapi: "3.0.0"
+info:
+ title: roleapi
+ version: "0.1.0"
+servers:
+ - url: https://api.example.com
+paths:
+ /roles:
+ get:
+ summary: List roles
+ responses:
+ '200':
+ description: ok
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Role'
+components:
+ schemas:
+ Role:
+ type: object
+ properties:
+ role_id:
+ type: string
+ description:
+ type: string
+ permissions:
+ type: array
+ items:
+ type: string
+ required:
+ - role_id
+ - description
+ - permissions
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err, "schema with a property named 'description' must parse")
+ require.Contains(t, parsed.Types, "Role")
+ role := parsed.Types["Role"]
+
+ var hasDescriptionField bool
+ for _, f := range role.Fields {
+ if f.Name == "description" {
+ hasDescriptionField = true
+ assert.Equal(t, "string", f.Type,
+ "property named 'description' must keep its declared schema type")
+ }
+ }
+ assert.True(t, hasDescriptionField,
+ "the description property must survive normalization")
+}
+
+// TestParseHandlesObjectDescriptionAtMultipleLevels guards against vendors
+// applying the object-shaped description pattern beyond tags. Walk-and-flatten
+// must operate on every description in the tree, not just tag-level ones.
+func TestParseHandlesObjectDescriptionAtMultipleLevels(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`
+openapi: "3.0.0"
+info:
+ title: nestedapi
+ version: "0.1.0"
+ description:
+ $ref: "intro.yml#/main"
+servers:
+ - url: https://api.example.com
+paths:
+ /widgets:
+ get:
+ summary: List
+ description:
+ $ref: "operation.yml#/widgets"
+ responses:
+ '200':
+ description: ok
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err, "spec with object-shaped descriptions at info and operation levels must parse")
+ assert.Equal(t, "nestedapi", parsed.Name)
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 849590d4..5418b3b2 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -131,6 +131,9 @@ func ParseLenient(data []byte) (*spec.APISpec, error) {
}
func parse(data []byte, lenient bool) (*spec.APISpec, error) {
+ if normalized, err := normalizeSpecData(data); err == nil {
+ data = normalized
+ }
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = lenient
doc, err := loader.LoadFromData(data)
← 1a95a78a fix(cli): prepend T to type names that match Go reserved wor
·
back to Cli Printing Press
·
fix(cli): refresh stale catalog entries and reject non-spec ee6bd881 →