← back to Cli Printing Press
fix(cli): anchor openapi loader normalization (#730)
a4b0eb30590d1d322d6895072aa709db5ed46d78 · 2026-05-08 08:53:13 -0700 · Trevin Chow
* fix(cli): anchor openapi loader normalization
* refactor(cli): simplify openapi parser loading
* fix(cli): preserve strict remote ref guard
Files touched
M cmd/manifest-gen/main.goM internal/cli/public_param_audit.goM internal/cli/root.goM internal/openapi/description_normalize.goM internal/openapi/description_normalize_test.goM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/dogfood.goM internal/pipeline/mcpsync/sync.goM internal/pipeline/publish.goM internal/pipeline/spec_detect.go
Diff
commit a4b0eb30590d1d322d6895072aa709db5ed46d78
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 8 08:53:13 2026 -0700
fix(cli): anchor openapi loader normalization (#730)
* fix(cli): anchor openapi loader normalization
* refactor(cli): simplify openapi parser loading
* fix(cli): preserve strict remote ref guard
---
cmd/manifest-gen/main.go | 8 ++-
internal/cli/public_param_audit.go | 6 +-
internal/cli/root.go | 23 ++++--
internal/openapi/description_normalize.go | 61 ++++++++++++++--
internal/openapi/description_normalize_test.go | 72 +++++++++++++++++++
internal/openapi/parser.go | 96 ++++++++++++++++++++++++--
internal/openapi/parser_test.go | 84 ++++++++++++++++++++++
internal/pipeline/dogfood.go | 17 ++---
internal/pipeline/mcpsync/sync.go | 2 +-
internal/pipeline/publish.go | 2 +-
internal/pipeline/spec_detect.go | 20 ------
11 files changed, 336 insertions(+), 55 deletions(-)
diff --git a/cmd/manifest-gen/main.go b/cmd/manifest-gen/main.go
index 1c2c7ddb..a04bef82 100644
--- a/cmd/manifest-gen/main.go
+++ b/cmd/manifest-gen/main.go
@@ -56,7 +56,11 @@ func main() {
var parsed *spec.APISpec
switch format {
case "openapi":
- parsed, err = openapi.ParseLenient(data)
+ if openapi.IsRemoteSpecSource(*specFlag) {
+ parsed, err = openapi.ParseLenient(data)
+ } else {
+ parsed, err = openapi.ParseWithPathLenient(data, *specFlag)
+ }
case "internal":
parsed, err = spec.ParseBytes(data)
case "graphql":
@@ -107,7 +111,7 @@ func main() {
}
func loadSpec(source string) ([]byte, error) {
- if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
+ if openapi.IsRemoteSpecSource(source) {
resp, err := http.Get(source)
if err != nil {
return nil, fmt.Errorf("fetching %s: %w", source, err)
diff --git a/internal/cli/public_param_audit.go b/internal/cli/public_param_audit.go
index 5cb2cefc..23db035f 100644
--- a/internal/cli/public_param_audit.go
+++ b/internal/cli/public_param_audit.go
@@ -93,11 +93,7 @@ func parsePublicParamAuditSpec(specFiles []string, cliName string, lenient bool)
var apiSpec *spec.APISpec
if openapi.IsOpenAPI(data) {
- if lenient {
- apiSpec, err = openapi.ParseLenient(data)
- } else {
- apiSpec, err = openapi.Parse(data)
- }
+ apiSpec, err = parseOpenAPISpec(specFile, data, lenient)
} else if graphql.IsGraphQLSDL(data) {
apiSpec, err = graphql.ParseSDLBytes(specFile, data)
} else {
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 5f463654..04e88dd9 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -260,11 +260,7 @@ func newGenerateCmd() *cobra.Command {
var apiSpec *spec.APISpec
if openapi.IsOpenAPI(data) {
- if lenient {
- apiSpec, err = openapi.ParseLenient(data)
- } else {
- apiSpec, err = openapi.Parse(data)
- }
+ apiSpec, err = parseOpenAPISpec(specFile, data, lenient)
} else if graphql.IsGraphQLSDL(data) {
apiSpec, err = graphql.ParseSDLBytes(specFile, data)
} else {
@@ -644,7 +640,7 @@ func inferTrafficAnalysisPath(specFiles []string, specSource string) string {
return ""
}
specPath := specFiles[0]
- if strings.HasPrefix(specPath, "http://") || strings.HasPrefix(specPath, "https://") {
+ if openapi.IsRemoteSpecSource(specPath) {
return ""
}
candidate := browsersniff.DefaultTrafficAnalysisPath(specPath)
@@ -657,7 +653,7 @@ func inferTrafficAnalysisPath(specFiles []string, specSource string) string {
func readSpec(specFile string, refresh bool, skipCache bool) ([]byte, error) {
var data []byte
var err error
- if strings.HasPrefix(specFile, "http://") || strings.HasPrefix(specFile, "https://") {
+ if openapi.IsRemoteSpecSource(specFile) {
data, err = fetchOrCacheSpec(specFile, refresh, skipCache)
} else {
data, err = os.ReadFile(specFile)
@@ -671,6 +667,19 @@ func readSpec(specFile string, refresh bool, skipCache bool) ([]byte, error) {
return data, nil
}
+func parseOpenAPISpec(specFile string, data []byte, lenient bool) (*spec.APISpec, error) {
+ if openapi.IsRemoteSpecSource(specFile) {
+ if lenient {
+ return openapi.ParseLenient(data)
+ }
+ return openapi.Parse(data)
+ }
+ if lenient {
+ return openapi.ParseWithPathLenient(data, specFile)
+ }
+ return openapi.ParseWithPath(data, specFile)
+}
+
func mergeSpecs(specs []*spec.APISpec, name string) *spec.APISpec {
if len(specs) == 1 {
return specs[0]
diff --git a/internal/openapi/description_normalize.go b/internal/openapi/description_normalize.go
index dcd148c1..42054752 100644
--- a/internal/openapi/description_normalize.go
+++ b/internal/openapi/description_normalize.go
@@ -3,6 +3,7 @@ package openapi
import (
"encoding/json"
"fmt"
+ "sort"
"gopkg.in/yaml.v3"
)
@@ -34,7 +35,7 @@ func normalizeSpecData(data []byte) ([]byte, error) {
return nil, fmt.Errorf("normalize spec: yaml unmarshal: %w", err)
}
root = convertToStringKeyed(root)
- flattenObjectDescriptions(root, "")
+ normalizeSpecTree(root, "", false)
out, err := json.Marshal(root)
if err != nil {
return nil, fmt.Errorf("normalize spec: json marshal: %w", err)
@@ -71,9 +72,10 @@ func convertToStringKeyed(node any) any {
}
}
-// 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.
+// normalizeSpecTree walks the decoded spec tree and applies tolerant rewrites
+// kin-openapi needs before loading real-world specs. It replaces non-scalar
+// `description` fields with an empty string and normalizes schema `examples`
+// values to the array shape OpenAPI expects.
//
// Skips the flatten when the immediate parent key is one whose children are
// user-named entries (Schema property names, response codes, pattern regexes,
@@ -83,7 +85,7 @@ func convertToStringKeyed(node any) any {
// ... } }` 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) {
+func normalizeSpecTree(node any, parentKey string, inSchema bool) {
switch v := node.(type) {
case map[string]any:
if !isNameKeyedParent(parentKey) {
@@ -94,16 +96,61 @@ func flattenObjectDescriptions(node any, parentKey string) {
}
}
}
+ if inSchema {
+ if examples, ok := v["examples"]; ok {
+ v["examples"] = normalizeExamplesValue(examples)
+ }
+ }
for key, value := range v {
- flattenObjectDescriptions(value, key)
+ normalizeSpecTree(value, key, childIsSchema(parentKey, key))
}
case []any:
for _, item := range v {
- flattenObjectDescriptions(item, "")
+ normalizeSpecTree(item, "", schemaArrayItems(parentKey))
}
}
}
+func normalizeExamplesValue(examples any) any {
+ switch v := examples.(type) {
+ case []any:
+ return v
+ case map[string]any:
+ keys := make([]string, 0, len(v))
+ for key := range v {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ out := make([]any, 0, len(keys))
+ for _, key := range keys {
+ out = append(out, v[key])
+ }
+ return out
+ default:
+ return []any{v}
+ }
+}
+
+func childIsSchema(parentKey, key string) bool {
+ switch parentKey {
+ case "schemas", "definitions", "properties", "patternProperties":
+ return true
+ }
+ switch key {
+ case "schema", "items", "additionalProperties", "not":
+ return true
+ }
+ return false
+}
+
+func schemaArrayItems(parentKey string) bool {
+ switch parentKey {
+ case "allOf", "oneOf", "anyOf":
+ return true
+ }
+ return false
+}
+
// 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
diff --git a/internal/openapi/description_normalize_test.go b/internal/openapi/description_normalize_test.go
index 3110eace..5ba0c9e4 100644
--- a/internal/openapi/description_normalize_test.go
+++ b/internal/openapi/description_normalize_test.go
@@ -1,6 +1,7 @@
package openapi
import (
+ "encoding/json"
"testing"
"github.com/stretchr/testify/assert"
@@ -146,3 +147,74 @@ paths:
require.NoError(t, err, "spec with object-shaped descriptions at info and operation levels must parse")
assert.Equal(t, "nestedapi", parsed.Name)
}
+
+func TestNormalizeSpecDataConvertsSchemaExamplesMap(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`
+openapi: "3.0.0"
+info:
+ title: Legacy Examples
+ version: "0.1.0"
+paths:
+ /actions:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ actionParameters:
+ type: object
+ examples:
+ first:
+ value: one
+ second:
+ value: two
+ examples:
+ named:
+ value:
+ actionParameters:
+ value: preserved
+ responses:
+ '200':
+ description: ok
+components:
+ schemas:
+ Action:
+ type: object
+ examples:
+ named:
+ actionParameters:
+ value: preserved
+ OnlyExamples:
+ examples:
+ first:
+ value: structurally schema
+`)
+
+ normalized, err := normalizeSpecData(data)
+ require.NoError(t, err)
+
+ var root map[string]any
+ require.NoError(t, json.Unmarshal(normalized, &root))
+
+ actionParameters := root["paths"].(map[string]any)["/actions"].(map[string]any)["post"].(map[string]any)["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["properties"].(map[string]any)["actionParameters"].(map[string]any)
+ require.IsType(t, []any{}, actionParameters["examples"])
+ assert.Len(t, actionParameters["examples"], 2)
+
+ mediaType := root["paths"].(map[string]any)["/actions"].(map[string]any)["post"].(map[string]any)["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
+ require.IsType(t, map[string]any{}, mediaType["examples"])
+
+ component := root["components"].(map[string]any)["schemas"].(map[string]any)["Action"].(map[string]any)
+ require.IsType(t, []any{}, component["examples"])
+ assert.Len(t, component["examples"], 1)
+
+ onlyExamples := root["components"].(map[string]any)["schemas"].(map[string]any)["OnlyExamples"].(map[string]any)
+ require.IsType(t, []any{}, onlyExamples["examples"])
+ assert.Len(t, onlyExamples["examples"], 1)
+
+ _, err = Parse(data)
+ require.NoError(t, err, "schema examples in map form must parse after normalization")
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 1f652934..8d1a791f 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -8,6 +8,7 @@ import (
"maps"
"net/url"
"os"
+ "path/filepath"
"regexp"
"slices"
"sort"
@@ -147,19 +148,63 @@ func Parse(data []byte) (*spec.APISpec, error) {
return parse(data, false)
}
+// ParseFile parses an OpenAPI spec from a file and resolves local external
+// refs relative to that file.
+func ParseFile(path string) (*spec.APISpec, error) {
+ return parseFile(path, false)
+}
+
+// ParseWithPath parses OpenAPI spec bytes and resolves local external refs
+// relative to the given file path.
+func ParseWithPath(data []byte, path string) (*spec.APISpec, error) {
+ return parseWithPath(data, path, false)
+}
+
// ParseLenient parses an OpenAPI spec, skipping validation errors from broken $refs.
// It logs warnings to stderr for any issues found but continues parsing.
func ParseLenient(data []byte) (*spec.APISpec, error) {
return parse(data, true)
}
+// ParseFileLenient parses an OpenAPI spec from a file and skips validation
+// errors from broken $refs after resolving local external refs relative to
+// that file.
+func ParseFileLenient(path string) (*spec.APISpec, error) {
+ return parseFile(path, true)
+}
+
+// ParseWithPathLenient parses OpenAPI spec bytes, resolving local external refs
+// relative to the given file path and skipping validation errors from broken
+// refs.
+func ParseWithPathLenient(data []byte, path string) (*spec.APISpec, error) {
+ return parseWithPath(data, path, true)
+}
+
+func parseFile(path string, lenient bool) (*spec.APISpec, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading OpenAPI spec: %w", err)
+ }
+ return parseWithPath(data, path, lenient)
+}
+
+func parseWithPath(data []byte, path string, lenient bool) (*spec.APISpec, error) {
+ location, err := fileLocation(path)
+ if err != nil {
+ return nil, err
+ }
+ return parseWithLocation(data, lenient, location)
+}
+
func parse(data []byte, lenient bool) (*spec.APISpec, error) {
+ return parseWithLocation(data, lenient, nil)
+}
+
+func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APISpec, error) {
if normalized, err := normalizeSpecData(data); err == nil {
data = normalized
}
- loader := openapi3.NewLoader()
- loader.IsExternalRefsAllowed = lenient
- doc, err := loader.LoadFromData(data)
+ doc, err := loadOpenAPIDoc(data, lenient, location)
if err != nil {
if !lenient {
return nil, fmt.Errorf("loading OpenAPI spec: %w", err)
@@ -172,7 +217,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
break // stripBrokenRefs couldn't remove anything
}
data = cleaned
- doc, err = loader.LoadFromData(data)
+ doc, err = loadOpenAPIDoc(data, lenient, location)
}
if err != nil {
return nil, fmt.Errorf("loading OpenAPI spec (even after cleanup): %w", err)
@@ -689,6 +734,49 @@ func authVarsExtension(raw any) ([]spec.AuthEnvVar, error) {
return out, nil
}
+func loadOpenAPIDoc(data []byte, lenient bool, location *url.URL) (*openapi3.T, error) {
+ loader := openapi3.NewLoader()
+ loader.IsExternalRefsAllowed = lenient || location != nil
+ allowLocalExternalRefs := location != nil
+ loader.ReadFromURIFunc = func(loader *openapi3.Loader, refLocation *url.URL) ([]byte, error) {
+ if !lenient {
+ if !allowLocalExternalRefs || !isFileURI(refLocation) {
+ return nil, fmt.Errorf("encountered disallowed external reference: %q", refLocation.String())
+ }
+ }
+ data, err := openapi3.DefaultReadFromURI(loader, refLocation)
+ if err != nil {
+ return nil, err
+ }
+ if normalized, err := normalizeSpecData(data); err == nil {
+ return normalized, nil
+ }
+ return data, nil
+ }
+ if location != nil {
+ return loader.LoadFromDataWithPath(data, location)
+ }
+ return loader.LoadFromData(data)
+}
+
+func isFileURI(location *url.URL) bool {
+ return location != nil && location.Path != "" && location.Host == "" &&
+ (location.Scheme == "" || location.Scheme == "file")
+}
+
+func fileLocation(path string) (*url.URL, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, fmt.Errorf("resolving OpenAPI spec path: %w", err)
+ }
+ return &url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}, nil
+}
+
+// IsRemoteSpecSource reports whether a spec source should be loaded as a URL.
+func IsRemoteSpecSource(source string) bool {
+ return strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://")
+}
+
func requiredStringField(m map[string]any, name string) (string, bool) {
raw, ok := m[name]
if !ok {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index db47a5ce..472b541c 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -50,6 +50,90 @@ func TestParsePetstore(t *testing.T) {
assert.Contains(t, parsed.Types, "Pet")
}
+func TestParseFileResolvesLocalRefsRelativeToSpecDir(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ appsDir := filepath.Join(dir, "apps")
+ commonDir := filepath.Join(dir, "common")
+ require.NoError(t, os.MkdirAll(appsDir, 0o755))
+ require.NoError(t, os.MkdirAll(commonDir, 0o755))
+
+ require.NoError(t, os.WriteFile(filepath.Join(commonDir, "schemas.json"), []byte(`{
+ "components": {
+ "schemas": {
+ "Widget": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"}
+ }
+ }
+ }
+ }
+}`), 0o644))
+
+ specPath := filepath.Join(appsDir, "openapi.yaml")
+ require.NoError(t, os.WriteFile(specPath, []byte(`
+openapi: 3.0.3
+info:
+ title: Modular Widgets
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+paths:
+ /widgets:
+ get:
+ operationId: listWidgets
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "../common/schemas.json#/components/schemas/Widget"
+`), 0o644))
+
+ parsed, err := ParseFile(specPath)
+ require.NoError(t, err)
+
+ var foundWidgetID bool
+ for _, typ := range parsed.Types {
+ for _, field := range typ.Fields {
+ if field.Name == "id" && field.Type == "string" {
+ foundWidgetID = true
+ }
+ }
+ }
+ assert.True(t, foundWidgetID, "external schema fields must be available after local ref resolution")
+}
+
+func TestParseWithPathRejectsRemoteRefsInStrictMode(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Remote Ref
+ version: 1.0.0
+paths:
+ /widgets:
+ get:
+ operationId: listWidgets
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ $ref: "https://example.com/schemas.json#/components/schemas/Widget"
+`)
+
+ _, err := ParseWithPath(data, filepath.Join(t.TempDir(), "openapi.yaml"))
+ require.ErrorContains(t, err, "encountered disallowed external reference")
+}
+
func TestParsePreservesResponseDiscriminatorAndEnumFields(t *testing.T) {
t.Parallel()
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 55916af0..1e2ce634 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -608,19 +608,20 @@ func writeDogfoodResults(report *DogfoodReport, dir string) error {
}
func loadDogfoodOpenAPISpec(specPath string) (*openAPISpec, error) {
- // Try internal YAML spec format first (starts with "name:" + "resources:").
- if internal, err := tryLoadInternalYAMLSpec(specPath); err != nil {
- return nil, err
- } else if internal != nil {
- return internalSpecToDogfoodSpec(internal), nil
- }
-
data, err := os.ReadFile(specPath)
if err != nil {
return nil, fmt.Errorf("reading spec: %w", err)
}
- parsed, parseErr := openapiparser.ParseLenient(data)
+ if isInternalYAMLSpec(data) {
+ internal, err := apispec.ParseBytes(data)
+ if err != nil {
+ return nil, fmt.Errorf("parsing internal YAML spec: %w", err)
+ }
+ return internalSpecToDogfoodSpec(internal), nil
+ }
+
+ parsed, parseErr := openapiparser.ParseWithPathLenient(data, specPath)
if parseErr == nil {
return &openAPISpec{
Paths: collectDogfoodSpecPaths(parsed.Resources),
diff --git a/internal/pipeline/mcpsync/sync.go b/internal/pipeline/mcpsync/sync.go
index 2bc691e1..4f9cf449 100644
--- a/internal/pipeline/mcpsync/sync.go
+++ b/internal/pipeline/mcpsync/sync.go
@@ -210,7 +210,7 @@ func loadArchivedSpec(cliDir string) (*spec.APISpec, error) {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
if openapi.IsOpenAPI(data) {
- return openapi.ParseLenient(data)
+ return openapi.ParseWithPathLenient(data, path)
}
if graphql.IsGraphQLSDL(data) {
return graphql.ParseSDLBytes(path, data)
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index ecf0bd87..41dd04fc 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -253,7 +253,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
)
switch m.SpecFormat {
case "openapi3":
- parsed, parseErr = openapi.Parse(data)
+ parsed, parseErr = openapi.ParseWithPath(data, specFile)
case "graphql":
parsed, parseErr = graphql.ParseSDLBytes(specFile, data)
case "internal":
diff --git a/internal/pipeline/spec_detect.go b/internal/pipeline/spec_detect.go
index 936c25a5..c8533f83 100644
--- a/internal/pipeline/spec_detect.go
+++ b/internal/pipeline/spec_detect.go
@@ -3,7 +3,6 @@ package pipeline
import (
"bytes"
"fmt"
- "os"
"slices"
"strings"
@@ -179,22 +178,3 @@ func collectInternalResourcePaths(r apispec.Resource, paths *[]string) {
collectInternalResourcePaths(sub, paths)
}
}
-
-// tryLoadInternalYAMLSpec reads specPath and, if it's an internal YAML spec,
-// parses it and returns the APISpec. Returns nil, nil if not internal YAML.
-func tryLoadInternalYAMLSpec(specPath string) (*apispec.APISpec, error) {
- data, err := os.ReadFile(specPath)
- if err != nil {
- return nil, fmt.Errorf("reading spec: %w", err)
- }
-
- if !isInternalYAMLSpec(data) {
- return nil, nil
- }
-
- parsed, err := apispec.ParseBytes(data)
- if err != nil {
- return nil, fmt.Errorf("parsing internal YAML spec: %w", err)
- }
- return parsed, nil
-}
← a9b9aa65 feat(cli): emit AGENTS.md for printed CLIs (fast-track of #6
·
back to Cli Printing Press
·
fix(cli): score structural scorer behavior (#732) 979510c2 →