diff --git a/AGENTS.md b/AGENTS.md
index 66f390dab..850761ea5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md
-AI agent context for `github.com/pb33f/libopenapi` — a Go library for parsing, indexing, mutating, bundling, diffing, overlaying, rendering, and executing OpenAPI/OAS-adjacent documents. Optimize for code-first maintenance: trust implementation and tests over README or published docs when they drift.
+`github.com/pb33f/libopenapi` is a Go library for parsing, indexing, mutating, bundling, diffing, overlaying, rendering, and mocking OpenAPI/OAS-adjacent documents. It is the engine behind vacuum, wiretap, openapi-changes, printing press, and the pb33f platform. When code, tests, and external docs disagree, code is canonical.
## Context
@@ -29,25 +29,25 @@ This repo is a library, not an app. The root package exposes the public entry po
| Path | Purpose |
|---|---|
-| [`document.go`](/Users/dashanle/work/libopenapi/libopenapi/document.go) | Root orchestration layer; keep it thin |
-| [`index/doc.go`](/Users/dashanle/work/libopenapi/libopenapi/index/doc.go) | Best summary of `index` subsystem boundaries and invariants |
-| [`index/index_model.go`](/Users/dashanle/work/libopenapi/libopenapi/index/index_model.go) | `SpecIndex`, config, caches, release lifecycle |
-| [`index/spec_index_build.go`](/Users/dashanle/work/libopenapi/libopenapi/index/spec_index_build.go) | Index construction/build sequencing |
-| [`index/rolodex.go`](/Users/dashanle/work/libopenapi/libopenapi/index/rolodex.go) | Cross-document lookup ownership and lifecycle |
-| [`index/extract_refs.go`](/Users/dashanle/work/libopenapi/libopenapi/index/extract_refs.go) | Reference discovery entry point |
-| [`index/find_component_entry.go`](/Users/dashanle/work/libopenapi/libopenapi/index/find_component_entry.go) | Component lookup entry path |
-| [`index/search_index.go`](/Users/dashanle/work/libopenapi/libopenapi/index/search_index.go) | Reference search flow, cache usage, schema-id lookup |
-| [`index/resolver_entry.go`](/Users/dashanle/work/libopenapi/libopenapi/index/resolver_entry.go) | Circular detection and destructive resolution entry point |
-| [`datamodel/document_config.go`](/Users/dashanle/work/libopenapi/libopenapi/datamodel/document_config.go) | Canonical config surface for documents/index/bundler behavior |
-| [`datamodel/spec_info.go`](/Users/dashanle/work/libopenapi/libopenapi/datamodel/spec_info.go) | Spec parsing, version detection, JSON conversion, `$self` handling |
-| [`datamodel/low/v3/create_document.go`](/Users/dashanle/work/libopenapi/libopenapi/datamodel/low/v3/create_document.go) | V3 document/index/rolodex assembly |
-| [`datamodel/low/model_builder.go`](/Users/dashanle/work/libopenapi/libopenapi/datamodel/low/model_builder.go) | Reflection-driven low-model population |
-| [`datamodel/high/node_builder.go`](/Users/dashanle/work/libopenapi/libopenapi/datamodel/high/node_builder.go) | High-model re-rendering/mutation path |
-| [`bundler/bundler.go`](/Users/dashanle/work/libopenapi/libopenapi/bundler/bundler.go) | Public bundling entry points/config |
-| [`bundler/bundler_composer.go`](/Users/dashanle/work/libopenapi/libopenapi/bundler/bundler_composer.go) | Composed bundling and component lifting |
-| [`what-changed/model/document.go`](/Users/dashanle/work/libopenapi/libopenapi/what-changed/model/document.go) | Unified change model and compare flow |
-| [`what-changed/model/breaking_rules.go`](/Users/dashanle/work/libopenapi/libopenapi/what-changed/model/breaking_rules.go) | Default/custom breaking-change policy |
-| [`.github/workflows/build.yaml`](/Users/dashanle/work/libopenapi/libopenapi/.github/workflows/build.yaml) | CI shape: Linux + Windows `go test ./...`, coverage upload |
+| `document.go` | Root orchestration layer; keep it thin |
+| `index/doc.go` | Best summary of `index` subsystem boundaries and invariants |
+| `index/index_model.go` | `SpecIndex`, config, caches, release lifecycle |
+| `index/spec_index_build.go` | Index construction/build sequencing |
+| `index/rolodex.go` | Cross-document lookup ownership and lifecycle |
+| `index/extract_refs.go` | Reference discovery entry point |
+| `index/find_component_entry.go` | Component lookup entry path |
+| `index/search_index.go` | Reference search flow, cache usage, schema-id lookup |
+| `index/resolver_entry.go` | Circular detection and destructive resolution entry point |
+| `datamodel/document_config.go` | Canonical config surface for documents/index/bundler behavior |
+| `datamodel/spec_info.go` | Spec parsing, version detection, JSON conversion, `$self` handling |
+| `datamodel/low/v3/create_document.go` | V3 document/index/rolodex assembly |
+| `datamodel/low/model_builder.go` | Reflection-driven low-model population |
+| `datamodel/high/node_builder.go` | High-model re-rendering/mutation path |
+| `bundler/bundler.go` | Public bundling entry points/config |
+| `bundler/bundler_composer.go` | Composed bundling and component lifting |
+| `what-changed/model/document.go` | Unified change model and compare flow |
+| `what-changed/model/breaking_rules.go` | Default/custom breaking-change policy |
+| `.github/workflows/build.yaml` | CI shape: Linux + Windows `go test ./...`, coverage upload |
## Commands
@@ -72,18 +72,53 @@ This repo is a library, not an app. The root package exposes the public entry po
## Rules & Patterns
-- Keep [`document.go`](/Users/dashanle/work/libopenapi/libopenapi/document.go) thin. Parsing/version detection belongs in `datamodel`, indexing/lookup/resolution in `index`, and diff logic in `what-changed`.
+- Keep `document.go` thin. Parsing/version detection belongs in `datamodel`, indexing/lookup/resolution in `index`, and diff logic in `what-changed`.
- Trust code and tests before README or `pb33f.io` docs.
- Prefer existing `index` seams over adding more orchestration: `extract_refs*` for discovery, `find_component*`/`search_*` for lookup, `resolver_*` for resolution, `rolodex*` for external docs, `schema_id*` for JSON Schema `$id`.
- Preserve ownership boundaries: one `SpecIndex` owns one parsed document; `Rolodex` owns shared file/remote lookup and cross-document indexes.
- Treat lifecycle work carefully. `Document.Release()` intentionally does not release the underlying `SpecIndex`; `SpecIndex.Release()` and `Rolodex.Release()` are separate cleanup steps for long-lived processes.
- Protect hot paths in `index` and schema resolution. The package explicitly optimizes direct component lookup, caches, pooled nodes, and reduced JSONPath usage on common paths.
- Add focused regression tests beside the behavior you change. This repo has a strong “surgical tests + high coverage” culture; preserve it.
-- If you touch sibling refs, merge semantics, quick-hash behavior, or schema proxy resolution, run both [`tests/`](/Users/dashanle/work/libopenapi/libopenapi/tests) and the relevant `what-changed` coverage/tests because these behaviors interact.
+- If you touch sibling refs, merge semantics, quick-hash behavior, or schema proxy resolution, run both `tests/` and the relevant `what-changed` coverage/tests because these behaviors interact.
- High-level models are mutable render facades over low-level YAML-backed models. Rendering or mutation fixes usually need checks in both `datamodel/high/*` and `datamodel/low/*`.
- `bundler` mutates models and depends on precise rolodex/index semantics. Ref rewrite or composition changes need bundler-specific tests, especially around discriminator mappings and external refs.
- `what-changed` is intentionally unified across OAS2 and OAS3+. Preserve both default breaking rules and override/config validation behavior.
-- Use realistic fixtures from [`test_specs/`](/Users/dashanle/work/libopenapi/libopenapi/test_specs) and package-local fixture dirs instead of inventing toy specs when reproducing parser/indexer bugs.
+- Use realistic fixtures from `test_specs/` and package-local fixture dirs instead of inventing toy specs when reproducing parser/indexer bugs.
+
+## Common Failure Modes
+
+- **Hash contract**: every schema field must appear in `Schema.hash()` (`datamodel/low/base/schema_hash.go`). A missing field means equality and diff silently ignore it. Call `ClearSchemaQuickHashMap()` between document lifecycles or the global `sync.Map` cache returns stale hashes.
+- **Circular refs**: `resolver_circular.go` detects loops by comparing `FullDefinition` strings. If ref rewriting (bundler, resolver) changes these inconsistently, loops go undetected and the resolver hangs or overflows the depth limit (500).
+- **Reference cache staleness**: `index.cache` (`sync.Map`) is never cleared after bundler mutations. Lookups after bundling can return stale pre-rewrite refs pointing to external files that no longer apply.
+- **Bundler irreversibility**: `BundleDocument` / `BundleDocumentComposed` mutates the model in-place permanently. Never compare, re-bundle, or re-index a document after bundling — parse fresh from the rendered output instead.
+- **Sibling ref idempotency**: `CreateAllOfStructure()` in `datamodel/low/base/sibling_ref_transformer.go` is not idempotent. Running it twice (e.g., bundle then re-index) produces nested `allOf` wrappers that break schema validity.
+- **Resolver state leak**: `IgnorePoly` and `IgnoreArray` flags on the resolver persist between parses. Reusing a resolver across documents causes the second document's polymorphic circular refs to be silently missed.
+
+## Mutation & Rendering
+
+The library uses a dual-model architecture:
+
+- **Low-level models** (`datamodel/low/`): YAML-backed structs that preserve line numbers, column numbers, comments, raw `*yaml.Node` references, and `$ref` metadata. These are the source of truth for document structure.
+- **High-level models** (`datamodel/high/`): Mutable Go structs that wrap a low model. Every high model stores a `low` field and exposes `GoLow()` to access it.
+
+**Mutation flow**:
+
+1. Modify fields on the high-level model (e.g., `doc.Info.Title = "New Title"`)
+2. Call `Render()` or `MarshalYAML()` on the model
+3. `MarshalYAML()` creates a `NodeBuilder(highModel, lowModel)` — the builder uses reflection to read high-model field values and low-model line numbers for ordering
+4. `NodeBuilder.Render()` sorts fields by original line number, then calls `AddYAMLNode()` recursively to build a `*yaml.Node` tree
+5. `yaml.Marshal()` serializes the node tree to bytes
+
+**Key rendering modes**:
+
+- Default (`Resolve = false`): references render as `$ref: ...` strings
+- Inline (`Resolve = true`): references are inlined at point of use
+- `RenderingModeBundle`: inlines refs but preserves `$ref` inside discriminator `oneOf`/`anyOf` for bundling compatibility
+- `RenderingModeValidation`: fully inlines everything for JSON Schema validation
+
+**`RenderAndReload()`** is destructive — it renders to bytes, then re-parses and rebuilds the entire document model from scratch. The old model is invalid after this call.
+
+**`renderer/` is separate**: the `renderer` package generates mock/example data from schemas (for documentation and testing). It does not serialize models to YAML — that is handled by `NodeBuilder` and `MarshalYAML()`.
## Environment
diff --git a/bundler/bundler.go b/bundler/bundler.go
index 863ac085c..6e0d0251a 100644
--- a/bundler/bundler.go
+++ b/bundler/bundler.go
@@ -28,6 +28,28 @@ import (
// ErrInvalidModel is returned when the model is not usable.
var ErrInvalidModel = errors.New("invalid model")
+type invalidModelBuildError struct {
+ cause error
+}
+
+func (e *invalidModelBuildError) Error() string {
+ if e == nil || e.cause == nil {
+ return ErrInvalidModel.Error()
+ }
+ return e.cause.Error()
+}
+
+func (e *invalidModelBuildError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.cause
+}
+
+func (e *invalidModelBuildError) Is(target error) bool {
+ return target == ErrInvalidModel
+}
+
// buildV3ModelFromBytes is a helper that parses bytes and builds a v3 model.
// Returns the model and any build errors. The model may be non-nil even when err is non-nil
// (e.g., circular reference warnings), allowing bundling to proceed with warnings.
@@ -39,7 +61,7 @@ func buildV3ModelFromBytes(bytes []byte, configuration *datamodel.DocumentConfig
v3Doc, buildErr := doc.BuildV3Model()
if v3Doc == nil {
- return nil, errors.Join(ErrInvalidModel, buildErr)
+ return nil, &invalidModelBuildError{cause: buildErr}
}
// Return both model and error - caller decides how to handle warnings/errors
return &v3Doc.Model, buildErr
@@ -76,7 +98,7 @@ func BundleBytesComposed(bytes []byte, configuration *datamodel.DocumentConfigur
v3Doc, err := doc.BuildV3Model()
if err != nil {
- return nil, errors.Join(ErrInvalidModel, err)
+ return nil, &invalidModelBuildError{cause: err}
}
bundledBytes, e := compose(&v3Doc.Model, compositionConfig)
@@ -93,7 +115,7 @@ func BundleBytesComposedWithOrigins(bytes []byte, configuration *datamodel.Docum
v3Doc, err := doc.BuildV3Model()
if err != nil {
- return nil, errors.Join(ErrInvalidModel, err)
+ return nil, &invalidModelBuildError{cause: err}
}
result, e := composeWithOrigins(&v3Doc.Model, compositionConfig)
diff --git a/bundler/bundler_composer.go b/bundler/bundler_composer.go
index edc8ce61a..5a351596b 100644
--- a/bundler/bundler_composer.go
+++ b/bundler/bundler_composer.go
@@ -155,6 +155,13 @@ func isOpenAPIRootKey(key string) bool {
return openAPIRootKeys[key]
}
+func rootSupportsPathItemComponents(rootIdx *index.SpecIndex) bool {
+ if rootIdx == nil || rootIdx.GetConfig() == nil || rootIdx.GetConfig().SpecInfo == nil {
+ return true
+ }
+ return rootIdx.GetConfig().SpecInfo.VersionNumeric >= 3.1
+}
+
// processReference will extract a reference from the current index, and transform it into a first class
// top-level component in the root OpenAPI document.
func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig) error {
@@ -163,6 +170,7 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
var err error
delim := cf.compositionConfig.Delimiter
+ supportsPathItemComponents := rootSupportsPathItemComponents(cf.rootIdx)
if model.Components != nil {
components = model.Components
@@ -205,7 +213,11 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
case v3low.CallbacksLabel:
location = handleFileImport(pr, v3low.CallbacksLabel, delim, components.Callbacks)
case v3low.PathItemsLabel:
- location = handleFileImport(pr, v3low.PathItemsLabel, delim, components.PathItems)
+ if supportsPathItemComponents {
+ location = handleFileImport(pr, v3low.PathItemsLabel, delim, components.PathItems)
+ } else {
+ cf.inlineRequired = append(cf.inlineRequired, pr)
+ }
}
} else {
// the only choice we can make here to be accurate is to inline instead of recompose.
@@ -275,10 +287,12 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
}
case v3low.PathItemsLabel:
- if len(location) > 2 && components.PathItems != nil {
+ if supportsPathItemComponents && len(location) > 2 && components.PathItems != nil {
return checkReferenceAndCapture(location[2], cf.compositionConfig.Delimiter,
v3low.PathItemsLabel, pr, idx, components.PathItems, buildPathItem, cf.origins)
}
+ cf.inlineRequired = append(cf.inlineRequired, pr)
+ return nil
}
}
} else {
@@ -356,11 +370,13 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
return checkReferenceAndCapture(pr.name, delim, v3low.CallbacksLabel, pr, idx, components.Callbacks, buildCallback, cf.origins)
}
case v3low.PathItemsLabel:
- if components.PathItems != nil {
+ if supportsPathItemComponents && components.PathItems != nil {
pr.name = checkForCollision(componentName, delim, pr, components.PathItems)
pr.location = []string{v3low.ComponentsLabel, v3low.PathItemsLabel, pr.name}
return checkReferenceAndCapture(pr.name, delim, v3low.PathItemsLabel, pr, idx, components.PathItems, buildPathItem, cf.origins)
}
+ cf.inlineRequired = append(cf.inlineRequired, pr)
+ return nil
}
}
}
diff --git a/bundler/bundler_composer_test.go b/bundler/bundler_composer_test.go
index ab221edfb..dd4377371 100644
--- a/bundler/bundler_composer_test.go
+++ b/bundler/bundler_composer_test.go
@@ -102,6 +102,39 @@ func TestBundleDocumentComposed(t *testing.T) {
assert.Equal(t, "composition delimiter cannot contain spaces", err.Error())
}
+func TestBundleDocumentComposed_PreservesYamlMergeOverrides(t *testing.T) {
+ model := buildIssue831Model(t)
+
+ bundledBytes, err := BundleDocumentComposed(model, &BundleCompositionConfig{Delimiter: "__"})
+ require.NoError(t, err)
+ assert.NotContains(t, string(bundledBytes), "!!merge")
+
+ bundledDoc := parseBundledV3Document(t, bundledBytes)
+ require.NotNil(t, bundledDoc.Components)
+
+ getResponse := bundledDoc.Components.Responses.GetOrZero("getServer")
+ updateResponse := bundledDoc.Components.Responses.GetOrZero("updateServer")
+ require.NotNil(t, getResponse)
+ require.NotNil(t, updateResponse)
+
+ assert.Equal(t, "Get one specific server", getResponse.Description)
+ assert.Equal(t, "Original response has a description that I expected to be overrode by this", updateResponse.Description)
+ assert.Nil(t, getResponse.Headers)
+ require.NotNil(t, updateResponse.Headers)
+
+ header := updateResponse.Headers.GetOrZero("X-RateLimit-Limit")
+ require.NotNil(t, header)
+ assert.Equal(t, "This header will not appear.", header.Description)
+
+ pathItem := bundledDoc.Paths.PathItems.GetOrZero("/example")
+ require.NotNil(t, pathItem)
+ require.NotNil(t, pathItem.Patch)
+
+ patchResponse := pathItem.Patch.Responses.FindResponseByCode(200)
+ require.NotNil(t, patchResponse)
+ assert.Equal(t, "#/components/responses/updateServer", patchResponse.GoLow().GetReference())
+}
+
func TestCheckReferenceAndBubbleUp(t *testing.T) {
err := checkReferenceAndBubbleUp[any]("test", "__",
&processRef{ref: &index.Reference{Node: &yaml.Node{}}},
@@ -1234,6 +1267,113 @@ properties:
}
}
+func TestBundleBytesComposed_BarePathItemFile_OAS30Inlines(t *testing.T) {
+ rootSpec := `openapi: 3.0.3
+paths:
+ /test:
+ $ref: 'pathitem.yaml'
+`
+
+ pathItemSpec := `get:
+ operationId: getTest
+ responses:
+ "200":
+ description: OK
+post:
+ operationId: createTest
+ responses:
+ "201":
+ description: Created
+`
+
+ tmp := t.TempDir()
+ write := func(name, src string) {
+ require.NoError(t, os.WriteFile(filepath.Join(tmp, name), []byte(src), 0644))
+ }
+ write("main.yaml", rootSpec)
+ write("pathitem.yaml", pathItemSpec)
+
+ mainBytes, _ := os.ReadFile(filepath.Join(tmp, "main.yaml"))
+
+ bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{
+ BasePath: tmp,
+ AllowFileReferences: true,
+ RecomposeRefs: true,
+ }, &BundleCompositionConfig{StrictValidation: true})
+ require.NoError(t, err)
+
+ var doc map[string]any
+ require.NoError(t, yaml.Unmarshal(bundled, &doc))
+
+ paths := doc["paths"].(map[string]any)
+ testPath := paths["/test"].(map[string]any)
+
+ _, hasRef := testPath["$ref"]
+ assert.False(t, hasRef, "OpenAPI 3.0.x should inline bare path item file refs")
+ assert.Contains(t, testPath, "get")
+ assert.Contains(t, testPath, "post")
+
+ if components, ok := doc["components"].(map[string]any); ok {
+ _, hasPathItems := components["pathItems"]
+ assert.False(t, hasPathItems, "OpenAPI 3.0.x should not synthesize components.pathItems")
+ }
+}
+
+func TestBundleBytesComposed_ComponentPathItemRef_OAS30Inlines(t *testing.T) {
+ rootSpec := `openapi: 3.0.3
+paths:
+ /test:
+ $ref: 'components.yaml#/components/pathItems/TestPath'
+`
+
+ componentsSpec := `components:
+ pathItems:
+ TestPath:
+ get:
+ operationId: getTest
+ responses:
+ "200":
+ description: OK
+ post:
+ operationId: createTest
+ responses:
+ "201":
+ description: Created
+`
+
+ tmp := t.TempDir()
+ write := func(name, src string) {
+ require.NoError(t, os.WriteFile(filepath.Join(tmp, name), []byte(src), 0644))
+ }
+ write("main.yaml", rootSpec)
+ write("components.yaml", componentsSpec)
+
+ mainBytes, _ := os.ReadFile(filepath.Join(tmp, "main.yaml"))
+
+ bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{
+ BasePath: tmp,
+ AllowFileReferences: true,
+ RecomposeRefs: true,
+ }, &BundleCompositionConfig{StrictValidation: true})
+ require.NoError(t, err)
+
+ var doc map[string]any
+ require.NoError(t, yaml.Unmarshal(bundled, &doc))
+
+ paths := doc["paths"].(map[string]any)
+ testPath := paths["/test"].(map[string]any)
+
+ _, hasRef := testPath["$ref"]
+ assert.False(t, hasRef, "OpenAPI 3.0.x should inline components.pathItems refs from external files")
+ assert.Contains(t, testPath, "get")
+ assert.Contains(t, testPath, "post")
+
+ if components, ok := doc["components"].(map[string]any); ok {
+ _, hasPathItems := components["pathItems"]
+ assert.False(t, hasPathItems, "OpenAPI 3.0.x should not synthesize components.pathItems")
+ }
+}
+
// TestBundleBytesComposed_SingleSegmentPointerMultipleRefs tests that multiple
// references to the same single-segment pointer are properly deduplicated.
func TestBundleBytesComposed_SingleSegmentPointerMultipleRefs(t *testing.T) {
@@ -2174,9 +2314,9 @@ paths:
assert.True(t, foundEventCallback, "EventCallback should be added to components")
}
-// TestBundleBytesComposed_SingleSegmentPathItem tests that single-segment JSON pointer
-// references to pathItem objects are properly recomposed to component references.
-func TestBundleBytesComposed_SingleSegmentPathItem(t *testing.T) {
+// TestBundleBytesComposed_SingleSegmentPathItem_OAS31 tests that single-segment JSON pointer
+// references to pathItem objects are properly recomposed to component references in OpenAPI 3.1+.
+func TestBundleBytesComposed_SingleSegmentPathItem_OAS31(t *testing.T) {
rootSpec := `openapi: 3.1.0
paths:
/test:
@@ -2243,6 +2383,61 @@ paths:
assert.True(t, foundTestPath, "TestPath should be added to components")
}
+// TestBundleBytesComposed_SingleSegmentPathItem_OAS30 tests that composed bundling
+// inlines external path items for OpenAPI 3.0.x instead of creating 3.1-only components.pathItems.
+func TestBundleBytesComposed_SingleSegmentPathItem_OAS30(t *testing.T) {
+ rootSpec := `openapi: 3.0.3
+paths:
+ /test:
+ $ref: 'pathitems.yaml#/TestPath'
+`
+
+ pathitemsFile := `TestPath:
+ get:
+ operationId: getTest
+ responses:
+ "200":
+ description: OK
+ post:
+ operationId: createTest
+ responses:
+ "201":
+ description: Created
+`
+
+ tmp := t.TempDir()
+ write := func(name, src string) {
+ require.NoError(t, os.WriteFile(filepath.Join(tmp, name), []byte(src), 0644))
+ }
+ write("main.yaml", rootSpec)
+ write("pathitems.yaml", pathitemsFile)
+
+ mainBytes, _ := os.ReadFile(filepath.Join(tmp, "main.yaml"))
+
+ bundled, err := BundleBytesComposed(mainBytes, &datamodel.DocumentConfiguration{
+ BasePath: tmp,
+ AllowFileReferences: true,
+ }, nil)
+ require.NoError(t, err)
+
+ var doc map[string]any
+ require.NoError(t, yaml.Unmarshal(bundled, &doc))
+
+ paths := doc["paths"].(map[string]any)
+ testPath := paths["/test"].(map[string]any)
+
+ _, hasRef := testPath["$ref"]
+ assert.False(t, hasRef, "PathItem should be inlined for OpenAPI 3.0.x")
+ assert.Contains(t, testPath, "get")
+ assert.Contains(t, testPath, "post")
+
+ components, hasComponents := doc["components"].(map[string]any)
+ if hasComponents {
+ _, hasPathItems := components["pathItems"]
+ assert.False(t, hasPathItems, "OpenAPI 3.0.x bundle should not contain components.pathItems")
+ }
+}
+
func TestBundlerComposed_AliasSchemaNoCircularSelfRef(t *testing.T) {
tmpDir := t.TempDir()
diff --git a/bundler/bundler_test.go b/bundler/bundler_test.go
index adad454a6..9ba3fc35b 100644
--- a/bundler/bundler_test.go
+++ b/bundler/bundler_test.go
@@ -5,6 +5,7 @@ package bundler
import (
"bytes"
+ "errors"
"fmt"
"log/slog"
"net/http"
@@ -93,6 +94,101 @@ func isEmptyRef(line string) bool {
return ref == "{}" || ref == ""
}
+func writeIssue831Fixture(t *testing.T) string {
+ t.Helper()
+
+ tmpDir := t.TempDir()
+ specs := `openapi: 3.1.0
+info:
+ version: 1.0.0
+ title: Example
+ description: Woe be me
+ license:
+ name: MIT
+servers:
+ - url: http://example.com/v1
+paths:
+ /example:
+ get:
+ operationId: GetServer
+ responses:
+ "200":
+ $ref: 'servers.yaml#/getServer'
+ patch:
+ operationId: UpdateServer
+ responses:
+ "200":
+ $ref: 'servers.yaml#/updateServer'
+`
+
+ servers := `getServer: &getServer
+ description: "Get one specific server"
+ content:
+ application/json:
+ schema:
+ $ref: "base.yaml#/base"
+
+updateServer:
+ <<: *getServer
+ description: "Original response has a description that I expected to be overrode by this"
+ headers:
+ X-RateLimit-Limit:
+ schema:
+ type: integer
+ description: This header will not appear.
+`
+
+ base := `base:
+ type: object
+ description: Base schema
+ properties:
+ enabled:
+ type: boolean
+ example:
+ enabled: true
+`
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "specs.yaml"), []byte(specs), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "servers.yaml"), []byte(servers), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "base.yaml"), []byte(base), 0644))
+ return filepath.Join(tmpDir, "specs.yaml")
+}
+
+func buildIssue831Model(t *testing.T) *v3high.Document {
+ t.Helper()
+
+ specPath := writeIssue831Fixture(t)
+ specBytes, err := os.ReadFile(specPath)
+ require.NoError(t, err)
+
+ cfg := &datamodel.DocumentConfiguration{
+ BasePath: filepath.Dir(specPath),
+ SpecFilePath: specPath,
+ AllowFileReferences: true,
+ ExtractRefsSequentially: true,
+ }
+
+ doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, cfg)
+ require.NoError(t, err)
+
+ v3Doc, errs := doc.BuildV3Model()
+ require.NoError(t, errs)
+ require.NotNil(t, v3Doc)
+ return &v3Doc.Model
+}
+
+func parseBundledV3Document(t *testing.T, bundledBytes []byte) *v3high.Document {
+ t.Helper()
+
+ bundledDoc, err := libopenapi.NewDocument(bundledBytes)
+ require.NoError(t, err)
+
+ bundledV3, errs := bundledDoc.BuildV3Model()
+ require.NoError(t, errs)
+ require.NotNil(t, bundledV3)
+ return &bundledV3.Model
+}
+
func TestBundleDocument_DigitalOcean(t *testing.T) {
// test the mother of all exploded specs.
tmp := checkoutDigitalOceanRepo(t)
@@ -265,6 +361,33 @@ components:
"All concurrent bundle operations should succeed")
}
+func TestBundleDocument_PreservesYamlMergeOverrides(t *testing.T) {
+ model := buildIssue831Model(t)
+
+ bundledBytes, err := BundleDocument(model)
+ require.NoError(t, err)
+
+ bundledDoc := parseBundledV3Document(t, bundledBytes)
+ pathItem := bundledDoc.Paths.PathItems.GetOrZero("/example")
+ require.NotNil(t, pathItem)
+ require.NotNil(t, pathItem.Get)
+ require.NotNil(t, pathItem.Patch)
+
+ getResponse := pathItem.Get.Responses.FindResponseByCode(200)
+ patchResponse := pathItem.Patch.Responses.FindResponseByCode(200)
+ require.NotNil(t, getResponse)
+ require.NotNil(t, patchResponse)
+
+ assert.Equal(t, "Get one specific server", getResponse.Description)
+ assert.Equal(t, "Original response has a description that I expected to be overrode by this", patchResponse.Description)
+ assert.Nil(t, getResponse.Headers)
+ require.NotNil(t, patchResponse.Headers)
+
+ header := patchResponse.Headers.GetOrZero("X-RateLimit-Limit")
+ require.NotNil(t, header)
+ assert.Equal(t, "This header will not appear.", header.Description)
+}
+
func TestBundleDocument_Circular(t *testing.T) {
digi, _ := os.ReadFile("../test_specs/circular-tests.yaml")
@@ -416,12 +539,10 @@ components:
_, e := BundleBytes(digi, config)
require.Error(t, e)
unwrap := utils.UnwrapErrors(e)
- require.Len(t, unwrap, 2)
+ require.Len(t, unwrap, 1)
assert.ErrorIs(t, unwrap[0], ErrInvalidModel)
- unwrapNext := utils.UnwrapErrors(unwrap[1])
- require.Len(t, unwrapNext, 2)
- assert.Equal(t, "component `bork` does not exist in the specification", unwrapNext[0].Error())
- assert.Equal(t, "cannot resolve reference `bork`, it's missing: $.bork [5:7]", unwrapNext[1].Error())
+ assert.Equal(t, "component `bork` does not exist in the specification\ncannot resolve reference `bork`, it's missing: $.bork [5:7]", unwrap[0].Error())
+ assert.NotContains(t, unwrap[0].Error(), "invalid model")
logEntries := strings.Split(byteBuf.String(), "\n")
if len(logEntries) == 1 && logEntries[0] == "" {
@@ -1799,6 +1920,44 @@ paths: {}`)
assert.Contains(t, err.Error(), "different version")
}
+func TestBundleBytesComposedWithOrigins_InvalidModel(t *testing.T) {
+ swagger2Spec := []byte(`swagger: "2.0"
+info:
+ title: Test API
+ version: 1.0.0
+paths: {}`)
+
+ _, err := BundleBytesComposedWithOrigins(swagger2Spec, nil, nil)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrInvalidModel)
+ assert.Contains(t, err.Error(), "different version")
+ assert.NotContains(t, err.Error(), "invalid model")
+}
+
+func TestInvalidModelBuildError(t *testing.T) {
+ t.Run("nil receiver", func(t *testing.T) {
+ var err *invalidModelBuildError
+ assert.Equal(t, ErrInvalidModel.Error(), err.Error())
+ assert.Nil(t, err.Unwrap())
+ })
+
+ t.Run("nil cause", func(t *testing.T) {
+ err := &invalidModelBuildError{}
+ assert.Equal(t, ErrInvalidModel.Error(), err.Error())
+ assert.Nil(t, err.Unwrap())
+ assert.ErrorIs(t, err, ErrInvalidModel)
+ })
+
+ t.Run("wrapped cause", func(t *testing.T) {
+ cause := errors.New("different version")
+ err := &invalidModelBuildError{cause: cause}
+ assert.Equal(t, cause.Error(), err.Error())
+ assert.ErrorIs(t, err, ErrInvalidModel)
+ assert.ErrorIs(t, err, cause)
+ assert.Equal(t, cause, err.Unwrap())
+ })
+}
+
// TestBundleBytesWithConfig_BackwardCompatibility tests that existing behavior is preserved
// when ResolveDiscriminatorExternalRefs is not enabled.
func TestBundleBytesWithConfig_BackwardCompatibility(t *testing.T) {
diff --git a/bundler/origin_test.go b/bundler/origin_test.go
index 8151af4e0..0e0c4234d 100644
--- a/bundler/origin_test.go
+++ b/bundler/origin_test.go
@@ -908,6 +908,53 @@ TestExample:
assert.Greater(t, len(componentTypes), 1, "should track multiple component types")
}
+func TestBundleBytesComposedWithOrigins_OAS30PathItemInlining(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ mainYAML := `openapi: 3.0.3
+paths:
+ /test:
+ $ref: './pathitems.yaml#/TestPath'
+`
+
+ pathitemsYAML := `TestPath:
+ get:
+ operationId: getTest
+ responses:
+ '200':
+ description: OK
+`
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "main.yaml"), []byte(mainYAML), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "pathitems.yaml"), []byte(pathitemsYAML), 0644))
+
+ config := &datamodel.DocumentConfiguration{
+ AllowFileReferences: true,
+ BasePath: tmpDir,
+ }
+
+ mainBytes, err := os.ReadFile(filepath.Join(tmpDir, "main.yaml"))
+ require.NoError(t, err)
+
+ result, err := BundleBytesComposedWithOrigins(mainBytes, config, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ var doc map[string]any
+ require.NoError(t, yaml.Unmarshal(result.Bytes, &doc))
+
+ paths := doc["paths"].(map[string]any)
+ testPath := paths["/test"].(map[string]any)
+ _, hasRef := testPath["$ref"]
+ assert.False(t, hasRef, "PathItem should be inlined for OpenAPI 3.0.x")
+ assert.Contains(t, testPath, "get")
+
+ for bundledRef, origin := range result.Origins {
+ assert.NotEqual(t, "pathItems", origin.ComponentType, "unexpected pathItems origin for %s", bundledRef)
+ assert.NotContains(t, bundledRef, "#/components/pathItems/")
+ }
+}
+
func TestCaptureOrigin_FullCoverage(t *testing.T) {
t.Run("captures with empty location", func(t *testing.T) {
origins := make(ComponentOriginMap)
diff --git a/datamodel/low/extraction_functions.go b/datamodel/low/extraction_functions.go
index 31970452f..e9e92857a 100644
--- a/datamodel/low/extraction_functions.go
+++ b/datamodel/low/extraction_functions.go
@@ -755,12 +755,12 @@ func ExtractMapNoLookupExtensions[PT Buildable[N], N any](
) (*orderedmap.Map[KeyReference[string], ValueReference[PT]], error) {
valueMap := orderedmap.New[KeyReference[string], ValueReference[PT]]()
var circError error
+ root = utils.NodeAlias(root)
+ utils.CheckForMergeNodes(root)
if utils.IsNodeMap(root) {
var currentKey *yaml.Node
skip := false
- rlen := len(root.Content)
-
- for i := 0; i < rlen; i++ {
+ for i := 0; i < len(root.Content); i++ {
node := root.Content[i]
if !includeExtensions {
if len(node.Value) >= 2 && (node.Value[0] == 'x' || node.Value[0] == 'X') && node.Value[1] == '-' {
@@ -777,12 +777,6 @@ func ExtractMapNoLookupExtensions[PT Buildable[N], N any](
continue
}
- if currentKey.Tag == "!!merge" && currentKey.Value == "<<" {
- root.Content = append(root.Content, utils.NodeAlias(node).Content...)
- rlen = len(root.Content)
- currentKey = nil
- continue
- }
node = utils.NodeAlias(node)
foundIndex := idx
@@ -1534,6 +1528,7 @@ func LocateRefEnd(ctx context.Context, root *yaml.Node, idx *index.SpecIndex, de
}
// FromReferenceMap will convert a *orderedmap.Map[KeyReference[K], ValueReference[V]] to a *orderedmap.Map[K, V]
+//
//go:noinline
func FromReferenceMap[K comparable, V any](refMap *orderedmap.Map[KeyReference[K], ValueReference[V]]) *orderedmap.Map[K, V] {
om := orderedmap.New[K, V]()
@@ -1544,6 +1539,7 @@ func FromReferenceMap[K comparable, V any](refMap *orderedmap.Map[KeyReference[K
}
// FromReferenceMapWithFunc will convert a *orderedmap.Map[KeyReference[K], ValueReference[V]] to a *orderedmap.Map[K, VOut] using a transform function
+//
//go:noinline
func FromReferenceMapWithFunc[K comparable, V any, VOut any](refMap *orderedmap.Map[KeyReference[K], ValueReference[V]], transform func(v V) VOut) *orderedmap.Map[K, VOut] {
om := orderedmap.New[K, VOut]()
diff --git a/datamodel/low/v3/create_document.go b/datamodel/low/v3/create_document.go
index 66b4db89f..6d037846a 100644
--- a/datamodel/low/v3/create_document.go
+++ b/datamodel/low/v3/create_document.go
@@ -49,6 +49,7 @@ func selectDocumentNode(root *yaml.Node, preferred documentTopLevelNode, label s
if root == nil {
return documentTopLevelNode{}
}
+ utils.CheckForMergeNodes(root)
if topOnly {
_, key, value := utils.FindKeyNodeFullTop(label, root.Content)
return documentTopLevelNode{key: key, value: value}
@@ -63,6 +64,7 @@ func collectDocumentTopLevelNodes(root *yaml.Node) documentTopLevelNodes {
if root == nil {
return nodes
}
+ utils.CheckForMergeNodes(root)
content := root.Content
for i := 0; i+1 < len(content); i += 2 {
diff --git a/datamodel/low/v3/response_test.go b/datamodel/low/v3/response_test.go
index 5539913c3..7e22f58b2 100644
--- a/datamodel/low/v3/response_test.go
+++ b/datamodel/low/v3/response_test.go
@@ -11,6 +11,7 @@ import (
"github.com/pb33f/libopenapi/index"
"github.com/pb33f/libopenapi/orderedmap"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
@@ -151,6 +152,50 @@ func TestResponse_Build_ScalarRoot(t *testing.T) {
assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value)
}
+func TestResponse_Build_PreservesMergeOverrides(t *testing.T) {
+ cleanHashCacheForTest(t)
+
+ yml := `getServer: &getServer
+ description: "Get one specific server"
+ content:
+ application/json:
+ schema:
+ type: string
+updateServer:
+ <<: *getServer
+ description: "Original response has a description that I expected to be overrode by this"
+ headers:
+ X-RateLimit-Limit:
+ schema:
+ type: integer
+ description: This header will not appear.`
+
+ var idxNode yaml.Node
+ _ = yaml.Unmarshal([]byte(yml), &idxNode)
+ idx := index.NewSpecIndex(&idxNode)
+
+ updateKeyNode := idxNode.Content[0].Content[2]
+ updateValueNode := idxNode.Content[0].Content[3]
+
+ var response Response
+ err := low.BuildModel(updateValueNode, &response)
+ require.NoError(t, err)
+ assert.Equal(t, "Original response has a description that I expected to be overrode by this", response.Description.Value)
+
+ err = response.Build(context.Background(), updateKeyNode, updateValueNode, idx)
+ require.NoError(t, err)
+ assert.Equal(t, "Original response has a description that I expected to be overrode by this", response.Description.Value)
+
+ header := response.FindHeader("X-RateLimit-Limit")
+ require.NotNil(t, header)
+ require.NotNil(t, header.Value)
+ assert.Equal(t, "This header will not appear.", header.Value.Description.Value)
+
+ content := response.FindContent("application/json")
+ require.NotNil(t, content)
+ require.NotNil(t, content.Value)
+}
+
func TestResponses_NoDefault(t *testing.T) {
cleanHashCacheForTest(t)
yml := `"200":
diff --git a/index/extract_refs.go b/index/extract_refs.go
index 8f48ba0bc..e0176ee41 100644
--- a/index/extract_refs.go
+++ b/index/extract_refs.go
@@ -38,11 +38,59 @@ func isArrayOfSchemaContainingNode(v string) bool {
// keyword (sample data, not schema). A segment named "example" or "examples" that is preceded
// by "properties" or "patternProperties" is a schema property name, not an OpenAPI keyword.
func underOpenAPIExamplePath(seenPath []string) bool {
- for i, p := range seenPath {
- if p == "example" || p == "examples" {
- if i == 0 || (seenPath[i-1] != "properties" && seenPath[i-1] != "patternProperties") {
+ for i := range seenPath {
+ if isOpenAPIExampleKeywordSegment(seenPath, i) {
+ return true
+ }
+ }
+ return false
+}
+
+func isOpenAPIExampleKeywordSegment(seenPath []string, idx int) bool {
+ if idx < 0 || idx >= len(seenPath) {
+ return false
+ }
+ switch seenPath[idx] {
+ case "example", "examples":
+ return idx == 0 || (seenPath[idx-1] != "properties" && seenPath[idx-1] != "patternProperties")
+ default:
+ return false
+ }
+}
+
+// underOpenAPIExamplePayloadPath reports whether seenPath points to raw example payload content.
+// A bare `example` path is not payload by itself because libopenapi still supports a direct
+// `$ref` wrapper there for bundling. Once traversal moves below `example`, or into an Example
+// Object's `value`/`dataValue`, the path is payload and nested `$ref` keys should be ignored.
+func underOpenAPIExamplePayloadPath(seenPath []string) bool {
+ for i := range seenPath {
+ if !isOpenAPIExampleKeywordSegment(seenPath, i) {
+ continue
+ }
+ switch seenPath[i] {
+ case "example":
+ if len(seenPath) > i+1 {
return true
}
+ case "examples":
+ if len(seenPath) > i+2 {
+ switch seenPath[i+2] {
+ case "value", "dataValue":
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+// isDirectOpenAPIExampleValuePath reports whether seenPath points at the value of an OpenAPI
+// `example` field itself. This is used to allow a top-level `$ref` wrapper while still skipping
+// traversal into arbitrary example payload objects.
+func isDirectOpenAPIExampleValuePath(seenPath []string) bool {
+ for i := range seenPath {
+ if seenPath[i] == "example" && isOpenAPIExampleKeywordSegment(seenPath, i) && len(seenPath) == i+1 {
+ return true
}
}
return false
diff --git a/index/extract_refs_ref.go b/index/extract_refs_ref.go
index 21317c170..850c120e4 100644
--- a/index/extract_refs_ref.go
+++ b/index/extract_refs_ref.go
@@ -27,6 +27,9 @@ func (index *SpecIndex) extractReferenceAt(
if len(node.Content) <= keyIndex+1 {
return nil
}
+ if underOpenAPIExamplePayloadPath(seenPath) {
+ return nil
+ }
isExtensionPath := false
for _, spi := range seenPath {
diff --git a/index/extract_refs_test.go b/index/extract_refs_test.go
index 6ed9b44fe..3df0df9e0 100644
--- a/index/extract_refs_test.go
+++ b/index/extract_refs_test.go
@@ -737,6 +737,59 @@ func TestUnderOpenAPIExamplePath(t *testing.T) {
}
}
+func TestUnderOpenAPIExamplePayloadPath(t *testing.T) {
+ tests := []struct {
+ name string
+ path []string
+ want bool
+ }{
+ {"empty", nil, false},
+ {"example_root", []string{"paths", "get", "responses", "200", "content", "application/json", "schema", "example"}, false},
+ {"nested_under_example_payload", []string{"components", "schemas", "Foo", "example", "nested"}, true},
+ {"examples_collection", []string{"components", "examples"}, false},
+ {"example_object_entry", []string{"components", "examples", "ReusableExample"}, false},
+ {"examples_value_payload", []string{"content", "application/json", "examples", "sample", "value"}, true},
+ {"examples_value_nested_payload", []string{"content", "application/json", "examples", "sample", "value", "nested"}, true},
+ {"examples_data_value_payload", []string{"components", "examples", "sample", "dataValue"}, true},
+ {"property_named_example", []string{"components", "schemas", "Foo", "properties", "example"}, false},
+ {"property_named_examples_value", []string{"components", "schemas", "Foo", "properties", "examples", "value"}, false},
+ {"real_example_after_property_example", []string{"components", "schemas", "Foo", "properties", "example", "example"}, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, underOpenAPIExamplePayloadPath(tt.path))
+ })
+ }
+}
+
+func TestIsOpenAPIExampleKeywordSegment(t *testing.T) {
+ path := []string{"components", "examples", "ReusableExample"}
+
+ tests := []struct {
+ name string
+ idx int
+ want bool
+ }{
+ {"negative index", -1, false},
+ {"index too large", len(path), false},
+ {"examples keyword", 1, true},
+ {"non keyword segment", 2, false},
+ {"property named example", 2, false},
+ }
+
+ propertyPath := []string{"components", "schemas", "Foo", "properties", "example"}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ targetPath := path
+ if tt.name == "property named example" {
+ targetPath = propertyPath
+ }
+ assert.Equal(t, tt.want, isOpenAPIExampleKeywordSegment(targetPath, tt.idx))
+ })
+ }
+}
+
func TestExtractRefs_InlineSchemaHelpers(t *testing.T) {
idx := NewTestSpecIndex().Load().(*SpecIndex)
idx.specAbsolutePath = "test.yaml"
@@ -833,6 +886,19 @@ func TestRegisterSchemaIDAt_HelperBranches(t *testing.T) {
assert.Equal(t, "://bad-base", entry.ParentId)
}
+func TestRegisterSchemaIDAt_SkipsExamplePaths(t *testing.T) {
+ idx := NewTestSpecIndex().Load().(*SpecIndex)
+ idx.specAbsolutePath = "test.yaml"
+
+ var node yaml.Node
+ _ = yaml.Unmarshal([]byte(`$id: https://example.com/schema.json`), &node)
+
+ idx.registerSchemaIDAt(node.Content[0], 0, []string{"components", "examples", "Sample", "value"}, "test.yaml")
+
+ assert.Empty(t, idx.schemaIdRegistry)
+ assert.Empty(t, idx.refErrors)
+}
+
func TestExtractRefs_MetadataHelpers(t *testing.T) {
idx := NewTestSpecIndex().Load().(*SpecIndex)
idx.specAbsolutePath = "test.yaml"
@@ -961,4 +1027,125 @@ func TestExtractRefs_WalkHelpers(t *testing.T) {
assert.False(t, shouldSkipMapSchemaCollection([]string{"properties", "example"}))
assert.False(t, shouldSkipMapSchemaCollection([]string{"patternProperties", "example"}))
assert.True(t, shouldSkipMapSchemaCollection([]string{"x-test"}))
+
+ var noAppendNode yaml.Node
+ _ = yaml.Unmarshal([]byte("summary: hello\nvalue: world"), &noAppendNode)
+ state.seenPath = []string{"components", "examples", "sample"}
+ state.lastAppended = false
+ idx.unwindExtractRefsPath(noAppendNode.Content[0], &state, 1)
+ assert.Equal(t, []string{"components", "examples", "sample"}, state.seenPath)
+
+ var appendNode yaml.Node
+ _ = yaml.Unmarshal([]byte("value: hello\nnext: world"), &appendNode)
+ state.lastAppended = true
+ idx.unwindExtractRefsPath(appendNode.Content[0], &state, 1)
+ assert.Equal(t, []string{"components", "examples"}, state.seenPath)
+ assert.False(t, state.lastAppended)
+}
+
+func TestExtractReferenceAt_IgnoresRefsInsideExamplePayloads(t *testing.T) {
+ idx := NewTestSpecIndex().Load().(*SpecIndex)
+ idx.specAbsolutePath = "test.yaml"
+
+ var refNode yaml.Node
+ _ = yaml.Unmarshal([]byte(`$ref: '#/components/schemas/Pet'`), &refNode)
+
+ ref := idx.extractReferenceAt(refNode.Content[0], nil, 0, []string{"components", "examples", "sample", "value"}, nil, false, "")
+ assert.Nil(t, ref)
+ assert.Empty(t, idx.GetAllReferences())
+ assert.Empty(t, idx.GetAllSequencedReferences())
+}
+
+func TestSpecIndex_ExtractRefs_ExampleObjectRefsIndexedButPayloadRefsIgnored(t *testing.T) {
+ spec := `openapi: 3.2.0
+info:
+ title: Example refs
+ version: 1.0.0
+paths:
+ /widgets:
+ get:
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ examples:
+ responseRef:
+ $ref: '#/components/examples/ReusableExample'
+ inlinePayload:
+ summary: payload example
+ value:
+ nested:
+ $ref: '#/components/schemas/ShouldNotIndex'
+components:
+ examples:
+ ReusableExample:
+ $ref: '#/components/examples/LeafExample'
+ LeafExample:
+ summary: reusable
+ value:
+ ok: true
+ DataValueExample:
+ dataValue:
+ nested:
+ $ref: '#/components/schemas/ShouldNotIndexData'
+ schemas:
+ ShouldNotIndex:
+ type: object
+ ShouldNotIndexData:
+ type: object
+`
+
+ var rootNode yaml.Node
+ _ = yaml.Unmarshal([]byte(spec), &rootNode)
+
+ idx := NewSpecIndexWithConfig(&rootNode, CreateOpenAPIIndexConfig())
+
+ rawRefs := make(map[string]bool)
+ for _, ref := range idx.GetAllReferences() {
+ rawRefs[ref.RawRef] = true
+ }
+
+ assert.True(t, rawRefs["#/components/examples/ReusableExample"])
+ assert.True(t, rawRefs["#/components/examples/LeafExample"])
+ assert.False(t, rawRefs["#/components/schemas/ShouldNotIndex"])
+ assert.False(t, rawRefs["#/components/schemas/ShouldNotIndexData"])
+}
+
+func TestSpecIndex_ExtractRefs_SchemaExampleRefIndexedButNestedPayloadRefsIgnored(t *testing.T) {
+ spec := `openapi: 3.2.0
+info:
+ title: Schema example refs
+ version: 1.0.0
+components:
+ schemas:
+ UsesExampleRef:
+ type: object
+ example:
+ $ref: '#/components/examples/ReusableExample'
+ InlineExamplePayload:
+ type: object
+ example:
+ nested:
+ $ref: '#/components/schemas/ShouldNotIndex'
+ ShouldNotIndex:
+ type: object
+ examples:
+ ReusableExample:
+ value:
+ ok: true
+`
+
+ var rootNode yaml.Node
+ _ = yaml.Unmarshal([]byte(spec), &rootNode)
+
+ idx := NewSpecIndexWithConfig(&rootNode, CreateOpenAPIIndexConfig())
+
+ rawRefs := make(map[string]bool)
+ for _, ref := range idx.GetAllReferences() {
+ rawRefs[ref.RawRef] = true
+ }
+
+ assert.True(t, rawRefs["#/components/examples/ReusableExample"])
+ assert.False(t, rawRefs["#/components/schemas/ShouldNotIndex"])
}
diff --git a/index/extract_refs_walk.go b/index/extract_refs_walk.go
index 694311fc5..6b93f3355 100644
--- a/index/extract_refs_walk.go
+++ b/index/extract_refs_walk.go
@@ -16,6 +16,7 @@ type extractRefsState struct {
scope *SchemaIdScope
parentBaseURI string
seenPath []string
+ lastAppended bool
level int
poly bool
polyName string
@@ -67,10 +68,6 @@ func (index *SpecIndex) walkExtractRefs(node, parent *yaml.Node, state *extractR
var found []*Reference
for i, n := range node.Content {
- if utils.IsNodeMap(n) || utils.IsNodeArray(n) {
- found = append(found, index.walkChildExtractRefs(n, node, state)...)
- }
-
// In YAML mapping nodes, Content alternates key-value: even indices (0, 2, 4...)
// are keys, odd indices (1, 3, 5...) are values.
if i%2 == 0 {
@@ -79,6 +76,10 @@ func (index *SpecIndex) walkExtractRefs(node, parent *yaml.Node, state *extractR
}
}
+ if utils.IsNodeMap(n) || utils.IsNodeArray(n) {
+ found = append(found, index.walkChildExtractRefs(n, node, state)...)
+ }
+
index.unwindExtractRefsPath(node, state, i)
}
@@ -86,6 +87,12 @@ func (index *SpecIndex) walkExtractRefs(node, parent *yaml.Node, state *extractR
}
func (index *SpecIndex) walkChildExtractRefs(node, parent *yaml.Node, state *extractRefsState) []*Reference {
+ if underOpenAPIExamplePayloadPath(state.seenPath) {
+ return nil
+ }
+ if isDirectOpenAPIExampleValuePath(state.seenPath) && !isDirectOpenAPIExampleRefNode(node) {
+ return nil
+ }
state.level++
if isPoly, _ := index.checkPolymorphicNode(state.prev); isPoly {
state.poly = true
@@ -96,6 +103,10 @@ func (index *SpecIndex) walkChildExtractRefs(node, parent *yaml.Node, state *ext
return index.ExtractRefs(state.ctx, node, parent, state.seenPath, state.level, state.poly, state.polyName)
}
+func isDirectOpenAPIExampleRefNode(node *yaml.Node) bool {
+ return utils.IsNodeMap(node) && utils.GetRefValueNode(node) != nil && len(node.Content) == 2
+}
+
func (index *SpecIndex) handleExtractRefsKey(
node, parent *yaml.Node,
state *extractRefsState,
@@ -103,6 +114,7 @@ func (index *SpecIndex) handleExtractRefsKey(
found *[]*Reference,
) bool {
keyNode := node.Content[keyIndex]
+ state.lastAppended = false
if keyNode == nil {
return false
}
@@ -134,6 +146,7 @@ func (index *SpecIndex) handleExtractRefsKey(
if keyNode.Value != "$ref" && keyNode.Value != "$id" && keyNode.Value != "" {
action := index.extractNodeMetadata(node, parent, state.seenPath, keyIndex)
+ state.lastAppended = action.appendSegment
if action.appendSegment {
state.seenPath = append(state.seenPath, strings.ReplaceAll(keyNode.Value, "/", "~1"))
state.prev = keyNode.Value
@@ -161,7 +174,9 @@ func (index *SpecIndex) unwindExtractRefsPath(node *yaml.Node, state *extractRef
return
}
next := node.Content[currentIndex+1]
- if currentIndex%2 != 0 && next != nil && !utils.IsNodeArray(next) && !utils.IsNodeMap(next) && len(state.seenPath) > 0 {
+ if currentIndex%2 != 0 && state.lastAppended &&
+ next != nil && !utils.IsNodeArray(next) && !utils.IsNodeMap(next) && len(state.seenPath) > 0 {
state.seenPath = state.seenPath[:len(state.seenPath)-1]
+ state.lastAppended = false
}
}
diff --git a/index/schema_id_test.go b/index/schema_id_test.go
index af0050e69..b086c30f5 100644
--- a/index/schema_id_test.go
+++ b/index/schema_id_test.go
@@ -1532,6 +1532,56 @@ components:
assert.True(t, found, "Should find invalid $id error")
}
+// Test that $id values embedded inside OpenAPI example payloads are ignored.
+func TestSchemaId_IgnoresIdsInsideExamplePayloads(t *testing.T) {
+ spec := `openapi: "3.1.0"
+info:
+ title: Test API
+ version: 1.0.0
+components:
+ schemas:
+ Widget:
+ type: object
+ properties:
+ outputSchema:
+ type: object
+ example:
+ definitions: {}
+ properties:
+ id:
+ $id: '#widget/example/id'
+ type: string
+ nested:
+ $id: '#widget/example/nested'
+ type: string
+ examples:
+ sample:
+ value:
+ child:
+ $id: '#widget/examples/child'
+ type: integer
+`
+
+ var rootNode yaml.Node
+ err := yaml.Unmarshal([]byte(spec), &rootNode)
+ assert.NoError(t, err)
+
+ config := CreateClosedAPIIndexConfig()
+ config.SpecAbsolutePath = "https://example.com/openapi.yaml"
+ index := NewSpecIndexWithConfig(&rootNode, config)
+ assert.NotNil(t, index)
+
+ allIds := index.GetAllSchemaIds()
+ assert.Len(t, allIds, 0)
+
+ errors := index.GetReferenceIndexErrors()
+ for _, e := range errors {
+ if e != nil {
+ assert.NotContains(t, e.Error(), "invalid $id")
+ }
+ }
+}
+
// Test fragment navigation with DocumentNode wrapper
func TestNavigateToFragment_DocumentNode(t *testing.T) {
yamlContent := `type: object
diff --git a/index/search_index_test.go b/index/search_index_test.go
index 02814e9ed..53b3e0f52 100644
--- a/index/search_index_test.go
+++ b/index/search_index_test.go
@@ -830,3 +830,15 @@ func TestIsFileBeingIndexed_HTTPPathMatch(t *testing.T) {
// Different path - should not match
assert.False(t, IsFileBeingIndexed(ctx, "https://example.com/other/file.yaml"))
}
+
+func TestIsFileBeingIndexed_HTTPMatchesLocalFilename(t *testing.T) {
+ ctx := context.Background()
+
+ files := map[string]bool{
+ "/tmp/specs/pet.yaml": true,
+ }
+ ctx = context.WithValue(ctx, IndexingFilesKey, files)
+
+ assert.True(t, IsFileBeingIndexed(ctx, "https://different-host.com/schemas/pet.yaml"))
+ assert.False(t, IsFileBeingIndexed(ctx, "https://different-host.com/schemas/cat.yaml"))
+}
diff --git a/renderer/mock_generator.go b/renderer/mock_generator.go
index 3ebf084d4..06860cb13 100644
--- a/renderer/mock_generator.go
+++ b/renderer/mock_generator.go
@@ -24,6 +24,7 @@ type MockType int
const (
JSON MockType = iota
YAML
+ XML
)
// MockGenerator is used to generate mocks for high-level mockable structs or *base.Schema pointers.
@@ -68,12 +69,57 @@ func (mg *MockGenerator) DisableRequiredCheck() {
mg.renderer.DisableRequiredCheck()
}
+// SetUnresolvedRefHandler sets a callback that is invoked when a $ref cannot be resolved during mock rendering.
+func (mg *MockGenerator) SetUnresolvedRefHandler(handler UnresolvedRefHandler) {
+ mg.renderer.SetUnresolvedRefHandler(handler)
+}
+
// SetSeed sets a specific seed for the random number generator used by this mock generator.
// This is useful for generating deterministic mocks for testing purposes.
func (mg *MockGenerator) SetSeed(seed int64) {
mg.renderer.SetSeed(seed)
}
+// extractSchema pulls the *base.Schema from a mockable struct or direct *base.Schema.
+// Returns an error for unresolved refs or build failures — preserving existing error behavior.
+func (mg *MockGenerator) extractSchema(mock any, v reflect.Value) (*highbase.Schema, error) {
+ switch reflect.TypeOf(mock) {
+ case reflect.TypeOf(&highbase.Schema{}):
+ return mock.(*highbase.Schema), nil
+ default:
+ schemaField := v.FieldByName(Schema)
+ if !schemaField.IsValid() {
+ return nil, nil
+ }
+ if sv, ok := schemaField.Interface().(*highbase.Schema); ok && sv != nil {
+ return sv, nil
+ }
+ if sv, ok := schemaField.Interface().(*highbase.SchemaProxy); ok && sv != nil {
+ schema := sv.Schema()
+ if schema == nil {
+ if sv.IsReference() {
+ return nil, fmt.Errorf("unable to resolve schema reference '%s' for mock generation",
+ sv.GetReference())
+ }
+ if err := sv.GetBuildError(); err != nil {
+ return nil, fmt.Errorf("unable to build schema for mock generation: %w", err)
+ }
+ }
+ return schema, nil
+ }
+ }
+ return nil, nil
+}
+
+// renderForType dispatches rendering based on the configured mock type.
+// For XML, it uses RenderXML with schema context; for JSON/YAML it uses renderMock.
+func (mg *MockGenerator) renderForType(value any, schema *highbase.Schema) []byte {
+ if mg.mockType == XML {
+ return mg.RenderXML(value, schema)
+ }
+ return mg.renderMock(value)
+}
+
// GenerateMock generates a mock for a given high-level mockable struct. The mockable struct must contain the following fields:
// Example: any type, this is the default example to use if no examples are present.
// Examples: *orderedmap.Map[string, *base.Example], this is a map of examples keyed by name.
@@ -106,20 +152,22 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) {
"fields (%s, %s)", fieldCount, Example, Examples)
}
+ // Extract schema EARLY so Example/Examples paths can use it for XML rendering
+ schemaValue, schemaErr := mg.extractSchema(mock, v)
+
var fallbackExample *highbase.Example = nil
// trying to find a named example
examples := v.FieldByName(Examples)
examplesValue := examples.Interface()
if examplesValue != nil && !examples.IsNil() {
-
- // cast examples to *orderedmap.Map[string, *highbase.Example]
- examplesMap := examplesValue.(*orderedmap.Map[string, *highbase.Example])
- if examplesMap.Len() > 0 {
- if example, ok := examplesMap.Get(name); ok {
- return mg.renderMock(example.Value), nil
- } else {
- //take the first example from the list
- fallbackExample = examplesMap.Oldest().Value
+ if examplesMap, ok := examplesValue.(*orderedmap.Map[string, *highbase.Example]); ok {
+ if examplesMap.Len() > 0 {
+ if example, ok := examplesMap.Get(name); ok {
+ return mg.renderForType(example.Value, schemaValue), nil
+ } else {
+ //take the first example from the list
+ fallbackExample = examplesMap.Oldest().Value
+ }
}
}
}
@@ -138,32 +186,18 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) {
}
if ex != nil {
// try and serialize the example value (very hacky since ex can be anything)
- return mg.renderMock(ex), nil
+ return mg.renderForType(ex, schemaValue), nil
}
}
// rendering fallback if it's not nil
if fallbackExample != nil {
- return mg.renderMock(fallbackExample.Value), nil
+ return mg.renderForType(fallbackExample.Value, schemaValue), nil
}
- // no examples? no problem, we can try and generate a mock from the schema.
- // check if this is a SchemaProxy, if not, then see if it has a Schema, if not, then we can't generate a mock.
- var schemaValue *highbase.Schema
- switch reflect.TypeOf(mock) {
- case reflect.TypeOf(&highbase.Schema{}):
- schemaValue = mock.(*highbase.Schema)
- default:
- if sv, ok := v.FieldByName(Schema).Interface().(*highbase.Schema); ok {
- if sv != nil {
- schemaValue = sv
- }
- }
- if sv, ok := v.FieldByName(Schema).Interface().(*highbase.SchemaProxy); ok {
- if sv != nil {
- schemaValue = sv.Schema()
- }
- }
+ // Surface schema extraction errors only after example paths have had their chance
+ if schemaErr != nil {
+ return nil, schemaErr
}
if schemaValue != nil {
@@ -174,17 +208,17 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) {
// try and convert the example to an integer
if i, err := strconv.Atoi(name); err == nil {
if i < len(schemaValue.Examples) {
- return mg.renderMock(schemaValue.Examples[i]), nil
+ return mg.renderForType(schemaValue.Examples[i], schemaValue), nil
}
}
}
// if the name is empty, just return the first example
- return mg.renderMock(schemaValue.Examples[0]), nil
+ return mg.renderForType(schemaValue.Examples[0], schemaValue), nil
}
// check the example field
if schemaValue.Example != nil {
- return mg.renderMock(schemaValue.Example), nil
+ return mg.renderForType(schemaValue.Example, schemaValue), nil
}
// render the schema as our last hope.
@@ -192,7 +226,7 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) {
if renderMap == nil {
return nil, fmt.Errorf("unable to render schema for mock, it's empty")
}
- return mg.renderMock(renderMap), nil
+ return mg.renderForType(renderMap, schemaValue), nil
}
return nil, nil
}
diff --git a/renderer/mock_generator_test.go b/renderer/mock_generator_test.go
index 198fdd8f0..b65b52699 100644
--- a/renderer/mock_generator_test.go
+++ b/renderer/mock_generator_test.go
@@ -351,6 +351,35 @@ properties:
assert.Equal(t, "a terrible show from a time that never existed.", m["description"].(string))
}
+func TestMockGenerator_GenerateJSONMock_DirectSchema_SchemaExamples(t *testing.T) {
+ yml := `type: object
+examples:
+ - name: happy days
+ description: a terrible show from a time that never existed.
+ - name: robocop
+ description: perhaps the best cyberpunk movie ever made.
+properties:
+ name:
+ type: string
+ example: nameExample
+ description:
+ type: string
+ example: descriptionExample`
+
+ fake := createFakeMockWithoutProxy(yml, nil, nil)
+ mg := NewMockGenerator(YAML)
+ mock, err := mg.GenerateMock(fake.Schema, "")
+ assert.NoError(t, err)
+
+ var m map[string]any
+ err = yaml.Unmarshal(mock, &m)
+ assert.NoError(t, err)
+
+ assert.Len(t, m, 2)
+ assert.Equal(t, "happy days", m["name"].(string))
+ assert.Equal(t, "a terrible show from a time that never existed.", m["description"].(string))
+}
+
func TestMockGenerator_GenerateJSONMock_Object_SchemaExamples_Preferred(t *testing.T) {
yml := `type: object
examples:
@@ -597,3 +626,110 @@ func TestMockGenerator_GenerateMock_GetInline(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "inline example", strings.TrimSpace(string(mock)))
}
+
+func TestMockGenerator_UnresolvedRefProperty(t *testing.T) {
+ // Construct a schema programmatically with one inline and one unresolved ref property
+ props := orderedmap.New[string, *base.SchemaProxy]()
+ props.Set("name", base.CreateSchemaProxy(&base.Schema{
+ Type: []string{"string"},
+ ParentProxy: base.CreateSchemaProxy(&base.Schema{}),
+ }))
+ props.Set("broken", base.CreateSchemaProxyRef("#/components/schemas/Missing"))
+
+ schema := &base.Schema{
+ Type: []string{"object"},
+ Properties: props,
+ ParentProxy: base.CreateSchemaProxy(&base.Schema{}),
+ }
+ schemaProxy := base.CreateSchemaProxy(schema)
+
+ fake := &fakeMockable{
+ Schema: schemaProxy,
+ Example: nil,
+ Examples: nil,
+ }
+ mg := NewMockGenerator(JSON)
+ mg.DisableRequiredCheck()
+ mock, err := mg.GenerateMock(fake, "")
+ assert.NoError(t, err)
+
+ var m map[string]any
+ err = json.Unmarshal(mock, &m)
+ assert.NoError(t, err)
+ assert.IsType(t, "", m["name"])
+ // "broken" should be null in JSON output
+ val, exists := m["broken"]
+ assert.True(t, exists)
+ assert.Nil(t, val)
+}
+
+func TestMockGenerator_SetUnresolvedRefHandler(t *testing.T) {
+ props := orderedmap.New[string, *base.SchemaProxy]()
+ props.Set("broken", base.CreateSchemaProxyRef("#/components/schemas/Missing"))
+
+ schema := &base.Schema{
+ Type: []string{"object"},
+ Properties: props,
+ ParentProxy: base.CreateSchemaProxy(&base.Schema{}),
+ }
+ schemaProxy := base.CreateSchemaProxy(schema)
+
+ fake := &fakeMockable{
+ Schema: schemaProxy,
+ Example: nil,
+ Examples: nil,
+ }
+ mg := NewMockGenerator(JSON)
+ mg.DisableRequiredCheck()
+
+ var callbackName string
+ mg.SetUnresolvedRefHandler(func(name string, proxy *base.SchemaProxy, err error) {
+ callbackName = name
+ })
+
+ _, err := mg.GenerateMock(fake, "")
+ assert.NoError(t, err)
+ assert.Equal(t, "broken", callbackName)
+}
+
+func TestMockGenerator_TopLevelUnresolvedRef(t *testing.T) {
+ mg := NewMockGenerator(JSON)
+ fake := &fakeMockable{
+ Schema: base.CreateSchemaProxyRef("#/components/schemas/Missing"),
+ Example: nil,
+ Examples: nil,
+ }
+ mock, err := mg.GenerateMock(fake, "")
+ assert.Error(t, err)
+ assert.Nil(t, mock)
+ assert.Contains(t, err.Error(), "#/components/schemas/Missing")
+ assert.Contains(t, err.Error(), "unable to resolve schema reference")
+}
+
+func TestMockGenerator_TopLevelBuildError(t *testing.T) {
+ // Create a low-level SchemaProxy with a nil value node. When Schema() is called,
+ // the low-level Schema.Build receives nil root and returns "cannot build schema from a nil node".
+ // This sets buildError on the proxy while IsReference() remains false.
+ sp := &lowbase.SchemaProxy{}
+ _ = sp.Build(context.Background(), nil, nil, nil)
+
+ highProxy := base.NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{
+ Value: sp,
+ })
+
+ // Verify preconditions
+ assert.Nil(t, highProxy.Schema())
+ assert.False(t, highProxy.IsReference())
+ assert.NotNil(t, highProxy.GetBuildError())
+
+ mg := NewMockGenerator(JSON)
+ fake := &fakeMockable{
+ Schema: highProxy,
+ Example: nil,
+ Examples: nil,
+ }
+ mock, err := mg.GenerateMock(fake, "")
+ assert.Error(t, err)
+ assert.Nil(t, mock)
+ assert.Contains(t, err.Error(), "unable to build schema for mock generation")
+}
diff --git a/renderer/mock_generator_xml.go b/renderer/mock_generator_xml.go
new file mode 100644
index 000000000..c75107802
--- /dev/null
+++ b/renderer/mock_generator_xml.go
@@ -0,0 +1,309 @@
+// Copyright 2024-2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package renderer
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "regexp"
+ "unicode"
+
+ highbase "github.com/pb33f/libopenapi/datamodel/high/base"
+ "go.yaml.in/yaml/v4"
+)
+
+// xmlNameRegex matches characters that are NOT valid in XML names.
+var xmlNameRegex = regexp.MustCompile(`[^a-zA-Z0-9._\-:]`)
+
+// sanitizeXMLName makes a string safe for use as an XML element or attribute name.
+// Invalid characters are replaced with '_'. Names starting with a digit get a '_' prefix.
+func sanitizeXMLName(name string) string {
+ if name == "" {
+ return "_"
+ }
+ s := xmlNameRegex.ReplaceAllString(name, "_")
+ if len(s) > 0 && (unicode.IsDigit(rune(s[0])) || s[0] == '-' || s[0] == '.') {
+ s = "_" + s
+ }
+ return s
+}
+
+// resolveNodeType determines the effective nodeType for a property schema, considering
+// both the OpenAPI 3.2+ nodeType field and the deprecated attribute/wrapped fields.
+func resolveNodeType(propSchema *highbase.Schema) string {
+ if propSchema == nil || propSchema.XML == nil {
+ return "element"
+ }
+ x := propSchema.XML
+ if x.NodeType != "" {
+ return x.NodeType
+ }
+ // Legacy backward compat
+ if x.Attribute {
+ return "attribute"
+ }
+ return "element"
+}
+
+// resolveElementName determines the XML element name for a property, using
+// the XML name override if available, otherwise sanitizing the map key.
+func resolveElementName(key string, propSchema *highbase.Schema) string {
+ if propSchema != nil && propSchema.XML != nil && propSchema.XML.Name != "" {
+ return propSchema.XML.Name
+ }
+ return sanitizeXMLName(key)
+}
+
+// getPropertySchema looks up the schema for a specific property name.
+func getPropertySchema(parentSchema *highbase.Schema, key string) *highbase.Schema {
+ if parentSchema == nil || parentSchema.Properties == nil {
+ return nil
+ }
+ if proxy, ok := parentSchema.Properties.Get(key); ok && proxy != nil {
+ return proxy.Schema()
+ }
+ return nil
+}
+
+// isWrappedArray returns true if an array schema should use a wrapper element.
+// In OpenAPI 3.2+ this is nodeType "element"; legacy uses wrapped: true.
+func isWrappedArray(schema *highbase.Schema) bool {
+ if schema == nil || schema.XML == nil {
+ return false
+ }
+ x := schema.XML
+ if x.NodeType == "element" {
+ return true
+ }
+ if x.NodeType == "" && x.Wrapped {
+ return true
+ }
+ return false
+}
+
+// buildStartElement creates an xml.StartElement with optional namespace prefix handling.
+func buildStartElement(name string, schema *highbase.Schema) xml.StartElement {
+ local := name
+ var attrs []xml.Attr
+
+ if schema != nil && schema.XML != nil {
+ x := schema.XML
+ if x.Prefix != "" && x.Namespace != "" {
+ local = x.Prefix + ":" + name
+ attrs = appendNamespaceAttr(attrs, x.Prefix, x.Namespace)
+ } else if x.Namespace != "" {
+ attrs = appendNamespaceAttr(attrs, "", x.Namespace)
+ }
+ }
+
+ return xml.StartElement{
+ Name: xml.Name{Local: local},
+ Attr: attrs,
+ }
+}
+
+func appendNamespaceAttr(attrs []xml.Attr, prefix, namespace string) []xml.Attr {
+ if namespace == "" {
+ return attrs
+ }
+ attrName := "xmlns"
+ if prefix != "" {
+ attrName = "xmlns:" + prefix
+ }
+ for _, attr := range attrs {
+ if attr.Name.Local == attrName {
+ return attrs
+ }
+ }
+ return append(attrs, xml.Attr{
+ Name: xml.Name{Local: attrName},
+ Value: namespace,
+ })
+}
+
+// RenderXML renders a value as XML. If schema is provided, uses its XML metadata
+// (xml.name, xml.attribute, xml.namespace, xml.prefix, xml.wrapped) for correct output.
+// If schema is nil, falls back to basic element-based XML (map keys → element names).
+//
+// Note: nodeType "cdata" is treated as "text" in this version — Go's xml.Encoder has
+// no first-class CDATA token support.
+func (mg *MockGenerator) RenderXML(value any, schema *highbase.Schema) []byte {
+ if value == nil {
+ return nil
+ }
+
+ // Decode *yaml.Node to native Go types
+ if y, ok := value.(*yaml.Node); ok {
+ var decoded any
+ if err := y.Decode(&decoded); err != nil {
+ return nil
+ }
+ value = decoded
+ }
+
+ var buf bytes.Buffer
+ buf.Grow(512)
+ enc := xml.NewEncoder(&buf)
+ if mg.pretty {
+ enc.Indent("", " ")
+ }
+
+ // XML declaration
+ _ = enc.EncodeToken(xml.ProcInst{Target: "xml", Inst: []byte(`version="1.0" encoding="UTF-8"`)})
+ if mg.pretty {
+ _ = enc.EncodeToken(xml.CharData("\n"))
+ }
+
+ // Root element name
+ rootName := "root"
+ if schema != nil && schema.XML != nil && schema.XML.Name != "" {
+ rootName = schema.XML.Name
+ }
+
+ start := buildStartElement(rootName, schema)
+
+ mg.renderXMLValue(enc, start, value, schema)
+
+ _ = enc.Flush()
+ return buf.Bytes()
+}
+
+// renderXMLValue recursively renders a value as XML tokens.
+func (mg *MockGenerator) renderXMLValue(enc *xml.Encoder, start xml.StartElement, value any, schema *highbase.Schema) {
+ if value == nil {
+ return
+ }
+
+ switch v := value.(type) {
+ case map[string]any:
+ mg.renderXMLMap(enc, start, v, schema)
+ case []any:
+ mg.renderXMLSlice(enc, start, v, schema)
+ default:
+ // Scalar value
+ _ = enc.EncodeToken(start)
+ _ = enc.EncodeToken(xml.CharData(fmt.Sprint(v)))
+ _ = enc.EncodeToken(start.End())
+ }
+}
+
+// renderXMLMap renders a map as an XML element with child elements, attributes, and text content.
+func (mg *MockGenerator) renderXMLMap(enc *xml.Encoder, start xml.StartElement, m map[string]any, schema *highbase.Schema) {
+ // Three-pass rendering:
+ // 1. Collect attributes → add to start element
+ // 2. Collect text/cdata nodes
+ // 3. Emit child elements
+
+ type childEntry struct {
+ key string
+ value any
+ schema *highbase.Schema
+ }
+
+ var textValues []any
+ var children []childEntry
+
+ for key, val := range m {
+ propSchema := getPropertySchema(schema, key)
+ nodeType := resolveNodeType(propSchema)
+
+ switch nodeType {
+ case "attribute":
+ attrName := resolveElementName(key, propSchema)
+ // Apply prefix for attributes too
+ if propSchema != nil && propSchema.XML != nil && propSchema.XML.Prefix != "" {
+ attrName = propSchema.XML.Prefix + ":" + attrName
+ start.Attr = appendNamespaceAttr(start.Attr, propSchema.XML.Prefix, propSchema.XML.Namespace)
+ }
+ start.Attr = append(start.Attr, xml.Attr{
+ Name: xml.Name{Local: attrName},
+ Value: fmt.Sprint(val),
+ })
+ case "text", "cdata":
+ textValues = append(textValues, val)
+ case "none":
+ // Skip the node itself, include sub-properties directly
+ if subMap, ok := val.(map[string]any); ok {
+ for sk, sv := range subMap {
+ children = append(children, childEntry{key: sk, value: sv, schema: getPropertySchema(propSchema, sk)})
+ }
+ } else {
+ children = append(children, childEntry{key: key, value: val, schema: propSchema})
+ }
+ default: // "element"
+ children = append(children, childEntry{key: key, value: val, schema: propSchema})
+ }
+ }
+
+ _ = enc.EncodeToken(start)
+
+ // Emit text content
+ for _, tv := range textValues {
+ _ = enc.EncodeToken(xml.CharData(fmt.Sprint(tv)))
+ }
+
+ // Emit child elements
+ for _, child := range children {
+ elemName := resolveElementName(child.key, child.schema)
+ childStart := buildStartElement(elemName, child.schema)
+
+ // Handle arrays
+ if arr, ok := child.value.([]any); ok {
+ mg.renderXMLArray(enc, childStart, arr, child.schema, child.key)
+ } else {
+ mg.renderXMLValue(enc, childStart, child.value, child.schema)
+ }
+ }
+
+ _ = enc.EncodeToken(start.End())
+}
+
+// renderXMLSlice renders a top-level slice (when the root value is an array).
+func (mg *MockGenerator) renderXMLSlice(enc *xml.Encoder, start xml.StartElement, arr []any, schema *highbase.Schema) {
+ _ = enc.EncodeToken(start)
+ itemSchema := mg.getItemsSchema(schema)
+ itemName := "item"
+ if itemSchema != nil && itemSchema.XML != nil && itemSchema.XML.Name != "" {
+ itemName = itemSchema.XML.Name
+ }
+ for _, item := range arr {
+ itemStart := buildStartElement(itemName, itemSchema)
+ mg.renderXMLValue(enc, itemStart, item, itemSchema)
+ }
+ _ = enc.EncodeToken(start.End())
+}
+
+// renderXMLArray renders an array property, handling wrapped vs unwrapped.
+func (mg *MockGenerator) renderXMLArray(enc *xml.Encoder, elemStart xml.StartElement, arr []any, propSchema *highbase.Schema, key string) {
+ itemSchema := mg.getItemsSchema(propSchema)
+ itemName := "item"
+ if itemSchema != nil && itemSchema.XML != nil && itemSchema.XML.Name != "" {
+ itemName = itemSchema.XML.Name
+ }
+
+ if isWrappedArray(propSchema) {
+ // Wrapped: ...
+ _ = enc.EncodeToken(elemStart)
+ for _, item := range arr {
+ itemStart := buildStartElement(itemName, itemSchema)
+ mg.renderXMLValue(enc, itemStart, item, itemSchema)
+ }
+ _ = enc.EncodeToken(elemStart.End())
+ } else {
+ // Unwrapped: repeated elements directly under parent
+ for _, item := range arr {
+ itemStart := buildStartElement(itemName, itemSchema)
+ mg.renderXMLValue(enc, itemStart, item, itemSchema)
+ }
+ }
+}
+
+// getItemsSchema extracts the items schema from an array schema.
+func (mg *MockGenerator) getItemsSchema(schema *highbase.Schema) *highbase.Schema {
+ if schema == nil || schema.Items == nil || !schema.Items.IsA() {
+ return nil
+ }
+ return schema.Items.A.Schema()
+}
diff --git a/renderer/mock_generator_xml_test.go b/renderer/mock_generator_xml_test.go
new file mode 100644
index 000000000..252e367b5
--- /dev/null
+++ b/renderer/mock_generator_xml_test.go
@@ -0,0 +1,849 @@
+// Copyright 2024-2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package renderer
+
+import (
+ "context"
+ "encoding/xml"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+ "github.com/pb33f/libopenapi/datamodel/low"
+ lowbase "github.com/pb33f/libopenapi/datamodel/low/base"
+ "github.com/pb33f/libopenapi/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.yaml.in/yaml/v4"
+)
+
+func createSchemaFromYAML(t *testing.T, yamlStr string) *base.Schema {
+ t.Helper()
+ var root yaml.Node
+ err := yaml.Unmarshal([]byte(yamlStr), &root)
+ require.NoError(t, err)
+ var lowProxy lowbase.SchemaProxy
+ err = lowProxy.Build(context.Background(), &root, root.Content[0], nil)
+ require.NoError(t, err)
+ lowRef := low.NodeReference[*lowbase.SchemaProxy]{
+ Value: &lowProxy,
+ }
+ highSchema := base.NewSchemaProxy(&lowRef)
+ return highSchema.Schema()
+}
+
+func TestRenderXML_NilValue(t *testing.T) {
+ mg := NewMockGenerator(XML)
+ result := mg.RenderXML(nil, nil)
+ assert.Nil(t, result)
+}
+
+func TestRenderXML_InvalidYAMLNodeDecode(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ // Unknown node kinds cannot be decoded into native Go values.
+ result := mg.RenderXML(&yaml.Node{Kind: 255}, nil)
+ assert.Nil(t, result)
+}
+
+func TestRenderXML_BasicMap_NoSchema(t *testing.T) {
+ mg := NewMockGenerator(XML)
+ mg.SetPretty()
+
+ value := map[string]any{
+ "name": "test",
+ "age": 42,
+ }
+
+ result := mg.RenderXML(value, nil)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, `test`)
+ assert.Contains(t, str, `42`)
+
+ // Verify it's valid XML
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_ScalarValues(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ tests := []struct {
+ name string
+ value any
+ expected string
+ }{
+ {"string", "hello", "hello"},
+ {"int", 42, "42"},
+ {"float", 3.14, "3.14"},
+ {"bool", true, "true"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := mg.RenderXML(tt.value, nil)
+ require.NotNil(t, result)
+ assert.Contains(t, string(result), tt.expected)
+ assertValidXML(t, result)
+ })
+ }
+}
+
+func TestRenderXML_XMLNameOverride(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: person
+properties:
+ firstName:
+ type: string
+ xml:
+ name: first-name
+ lastName:
+ type: string
+`)
+
+ value := map[string]any{
+ "firstName": "John",
+ "lastName": "Doe",
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, `John`)
+ assert.Contains(t, str, `Doe`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NodeTypeAttribute(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: item
+properties:
+ id:
+ type: integer
+ xml:
+ nodeType: attribute
+ name:
+ type: string
+`)
+
+ value := map[string]any{
+ "id": 100,
+ "name": "Widget",
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `id="100"`)
+ assert.Contains(t, str, `Widget`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_LegacyAttribute(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: item
+properties:
+ currency:
+ type: string
+ xml:
+ attribute: true
+ amount:
+ type: number
+`)
+
+ value := map[string]any{
+ "currency": "USD",
+ "amount": 120.50,
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `currency="USD"`)
+ assert.Contains(t, str, `120.5`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NodeTypeText(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: amount
+properties:
+ currency:
+ type: string
+ xml:
+ nodeType: attribute
+ value:
+ type: number
+ xml:
+ nodeType: text
+`)
+
+ value := map[string]any{
+ "currency": "USD",
+ "value": 120.50,
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ // Should produce 120.5
+ assert.Contains(t, str, `currency="USD"`)
+ assert.Contains(t, str, `120.5`)
+ // Should NOT have wrapper
+ assert.NotContains(t, str, ``)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NodeTypeCdata_FallsBackToText(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: note
+properties:
+ content:
+ type: string
+ xml:
+ nodeType: cdata
+`)
+
+ value := map[string]any{
+ "content": "Some text content",
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `Some text content`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_InvalidXMLNames(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ value := map[string]any{
+ "my key": "value1",
+ "123start": "value2",
+ "normal": "value3",
+ }
+
+ result := mg.RenderXML(value, nil)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `value1`)
+ assert.Contains(t, str, `<_123start>value2`)
+ assert.Contains(t, str, `value3`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NamespaceAndPrefix(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: order
+ namespace: http://example.com/schema
+ prefix: ex
+properties:
+ id:
+ type: integer
+`)
+
+ value := map[string]any{
+ "id": 42,
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `ex:order`)
+ assert.Contains(t, str, `xmlns:ex="http://example.com/schema"`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_AttributePrefixDeclaresNamespace(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: order
+properties:
+ id:
+ type: integer
+ xml:
+ nodeType: attribute
+ prefix: ex
+ namespace: http://example.com/attr
+ name:
+ type: string
+`)
+
+ value := map[string]any{
+ "id": 42,
+ "name": "Widget",
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `ex:id="42"`)
+ assert.Contains(t, str, `xmlns:ex="http://example.com/attr"`)
+ assert.Contains(t, str, `Widget`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_ArrayUnwrapped(t *testing.T) {
+ mg := NewMockGenerator(XML)
+ mg.SetPretty()
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: order
+properties:
+ tags:
+ type: array
+ items:
+ type: string
+ xml:
+ name: tag
+`)
+
+ value := map[string]any{
+ "tags": []any{"food", "drink"},
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `food`)
+ assert.Contains(t, str, `drink`)
+ // Should NOT have a wrapper
+ assert.NotContains(t, str, ``)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_ArrayWrapped(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: order
+properties:
+ tags:
+ type: array
+ xml:
+ wrapped: true
+ items:
+ type: string
+ xml:
+ name: tag
+`)
+
+ value := map[string]any{
+ "tags": []any{"food", "drink"},
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, `food`)
+ assert.Contains(t, str, `drink`)
+ assert.Contains(t, str, ``)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_ArrayWrappedByNodeTypeElement(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: order
+properties:
+ tags:
+ type: array
+ xml:
+ nodeType: element
+ items:
+ type: string
+ xml:
+ name: tag
+`)
+
+ value := map[string]any{
+ "tags": []any{"food", "drink"},
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, `food`)
+ assert.Contains(t, str, `drink`)
+ assert.Contains(t, str, ``)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_ArrayXMLNameDoesNotImplyWrapping(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: order
+properties:
+ tags:
+ type: array
+ xml:
+ name: collection
+ items:
+ type: string
+ xml:
+ name: tag
+`)
+
+ value := map[string]any{
+ "tags": []any{"food", "drink"},
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.NotContains(t, str, ``)
+ assert.Contains(t, str, `food`)
+ assert.Contains(t, str, `drink`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_ArrayItemsDefaultName(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: data
+properties:
+ items:
+ type: array
+ xml:
+ wrapped: true
+ items:
+ type: string
+`)
+
+ value := map[string]any{
+ "items": []any{"a", "b"},
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ // Without xml.Name on items, should default to "item"
+ assert.Contains(t, str, `- a
`)
+ assert.Contains(t, str, `- b
`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NestedObjects(t *testing.T) {
+ mg := NewMockGenerator(XML)
+ mg.SetPretty()
+
+ value := map[string]any{
+ "person": map[string]any{
+ "name": "Alice",
+ "address": map[string]any{
+ "city": "London",
+ "country": "UK",
+ },
+ },
+ }
+
+ result := mg.RenderXML(value, nil)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, `Alice`)
+ assert.Contains(t, str, ``)
+ assert.Contains(t, str, `London`)
+ assert.Contains(t, str, `UK`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NodeTypeNoneFlattensChildren(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: person
+properties:
+ profile:
+ type: object
+ xml:
+ nodeType: none
+ properties:
+ firstName:
+ type: string
+ city:
+ type: string
+`)
+
+ value := map[string]any{
+ "profile": map[string]any{
+ "firstName": "Alice",
+ "city": "London",
+ "nickname": "Al",
+ },
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.NotContains(t, str, ``)
+ assert.Contains(t, str, `Alice`)
+ assert.Contains(t, str, `London`)
+ assert.Contains(t, str, `Al`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_NodeTypeNoneScalarFallsBackToElement(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ schema := createSchemaFromYAML(t, `
+type: object
+xml:
+ name: person
+properties:
+ nickname:
+ type: string
+ xml:
+ nodeType: none
+`)
+
+ value := map[string]any{
+ "nickname": "Al",
+ }
+
+ result := mg.RenderXML(value, schema)
+ require.NotNil(t, result)
+ str := string(result)
+ assert.Contains(t, str, `Al`)
+ assertValidXML(t, result)
+}
+
+func TestRenderXML_Escaping(t *testing.T) {
+ mg := NewMockGenerator(XML)
+
+ value := map[string]any{
+ "text": ` & more`,
+ }
+
+ result := mg.RenderXML(value, nil)
+ require.NotNil(t, result)
+ str := string(result)
+ // xml.Encoder should escape all special characters
+ assert.NotContains(t, str, `