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, `