[object Object]

← 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

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 →