diff --git a/datamodel/high/v3/components_test.go b/datamodel/high/v3/components_test.go index 353182e1..60360184 100644 --- a/datamodel/high/v3/components_test.go +++ b/datamodel/high/v3/components_test.go @@ -206,6 +206,146 @@ paths: {} assert.Contains(t, logOutput, "\"section\":\"schemas\"") } +func TestComponents_BuildComponentValueReferences(t *testing.T) { + low.ClearHashCache() + tmpDir := t.TempDir() + + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + parameters: + ExternalParam: + $ref: "./params.yaml#/ExternalParam" + LocalTargetParam: + name: local + in: query + schema: + type: string + LocalParamRef: + $ref: "#/components/parameters/LocalTargetParam" + responses: + ExternalResponse: + $ref: "./responses.yaml#/ExternalResponse" + headers: + ExternalHeader: + $ref: "./headers.yaml#/ExternalHeader" + requestBodies: + ExternalBody: + $ref: "./request-bodies.yaml#/ExternalBody" +paths: {} +` + + params := `ExternalParam: + name: tenant + in: header + required: true + schema: + type: string +` + + responses := `ExternalResponse: + description: external response + content: + application/json: + schema: + type: object +` + + headers := `ExternalHeader: + description: external header + schema: + type: string +` + + requestBodies := `ExternalBody: + description: external body + required: true + content: + application/json: + schema: + type: object +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "params.yaml"), []byte(params), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "responses.yaml"), []byte(responses), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "headers.yaml"), []byte(headers), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "request-bodies.yaml"), []byte(requestBodies), 0o644)) + + info, err := datamodel.ExtractSpecInfo([]byte(spec)) + require.NoError(t, err) + + cfg := &datamodel.DocumentConfiguration{ + BasePath: tmpDir, + AllowFileReferences: true, + } + lowDoc, err := v3.CreateDocumentFromConfig(info, cfg) + require.NoError(t, err) + + doc := NewDocument(lowDoc) + require.NotNil(t, doc.Components) + + externalParam := doc.Components.Parameters.GetOrZero("ExternalParam") + require.NotNil(t, externalParam) + assert.Equal(t, "tenant", externalParam.Name) + assert.Equal(t, "header", externalParam.In) + require.NotNil(t, externalParam.Required) + assert.True(t, *externalParam.Required) + assert.True(t, externalParam.GoLow().IsReference()) + assert.Equal(t, "./params.yaml#/ExternalParam", externalParam.GoLow().GetReference()) + lowExternalParam := low.FindItemInOrderedMap[*v3.Parameter]("ExternalParam", doc.Components.GoLow().Parameters.Value) + require.NotNil(t, lowExternalParam) + assert.True(t, lowExternalParam.IsReference()) + assert.Equal(t, "./params.yaml#/ExternalParam", lowExternalParam.GetReference()) + + localParam := doc.Components.Parameters.GetOrZero("LocalParamRef") + require.NotNil(t, localParam) + assert.Equal(t, "local", localParam.Name) + assert.Equal(t, "query", localParam.In) + assert.True(t, localParam.GoLow().IsReference()) + assert.Equal(t, "#/components/parameters/LocalTargetParam", localParam.GoLow().GetReference()) + lowLocalParam := low.FindItemInOrderedMap[*v3.Parameter]("LocalParamRef", doc.Components.GoLow().Parameters.Value) + require.NotNil(t, lowLocalParam) + assert.True(t, lowLocalParam.IsReference()) + assert.Equal(t, "#/components/parameters/LocalTargetParam", lowLocalParam.GetReference()) + + externalResponse := doc.Components.Responses.GetOrZero("ExternalResponse") + require.NotNil(t, externalResponse) + assert.Equal(t, "external response", externalResponse.Description) + assert.NotNil(t, externalResponse.Content.GetOrZero("application/json")) + assert.True(t, externalResponse.GoLow().IsReference()) + assert.Equal(t, "./responses.yaml#/ExternalResponse", externalResponse.GoLow().GetReference()) + lowExternalResponse := low.FindItemInOrderedMap[*v3.Response]("ExternalResponse", doc.Components.GoLow().Responses.Value) + require.NotNil(t, lowExternalResponse) + assert.True(t, lowExternalResponse.IsReference()) + assert.Equal(t, "./responses.yaml#/ExternalResponse", lowExternalResponse.GetReference()) + + externalHeader := doc.Components.Headers.GetOrZero("ExternalHeader") + require.NotNil(t, externalHeader) + assert.Equal(t, "external header", externalHeader.Description) + assert.NotNil(t, externalHeader.Schema) + assert.True(t, externalHeader.GoLow().IsReference()) + assert.Equal(t, "./headers.yaml#/ExternalHeader", externalHeader.GoLow().GetReference()) + lowExternalHeader := low.FindItemInOrderedMap[*v3.Header]("ExternalHeader", doc.Components.GoLow().Headers.Value) + require.NotNil(t, lowExternalHeader) + assert.True(t, lowExternalHeader.IsReference()) + assert.Equal(t, "./headers.yaml#/ExternalHeader", lowExternalHeader.GetReference()) + + externalBody := doc.Components.RequestBodies.GetOrZero("ExternalBody") + require.NotNil(t, externalBody) + assert.Equal(t, "external body", externalBody.Description) + require.NotNil(t, externalBody.Required) + assert.True(t, *externalBody.Required) + assert.NotNil(t, externalBody.Content.GetOrZero("application/json")) + assert.True(t, externalBody.GoLow().IsReference()) + assert.Equal(t, "./request-bodies.yaml#/ExternalBody", externalBody.GoLow().GetReference()) + lowExternalBody := low.FindItemInOrderedMap[*v3.RequestBody]("ExternalBody", doc.Components.GoLow().RequestBodies.Value) + require.NotNil(t, lowExternalBody) + assert.True(t, lowExternalBody.IsReference()) + assert.Equal(t, "./request-bodies.yaml#/ExternalBody", lowExternalBody.GetReference()) +} + func TestComponents_warnPreservedComponentMapRefs_Guards(t *testing.T) { var nilComp *Components nilComp.warnPreservedComponentMapRefs() diff --git a/datamodel/low/v3/component_value_reference_coverage_test.go b/datamodel/low/v3/component_value_reference_coverage_test.go new file mode 100644 index 00000000..6a25f096 --- /dev/null +++ b/datamodel/low/v3/component_value_reference_coverage_test.go @@ -0,0 +1,200 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package v3 + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +type refAwareLowModel interface { + Build(context.Context, *yaml.Node, *yaml.Node, *index.SpecIndex) error + GetKeyNode() *yaml.Node + GetReference() string + GetReferenceNode() *yaml.Node + GetRootNode() *yaml.Node + IsReference() bool +} + +type scalarRootLowModel interface { + Build(context.Context, *yaml.Node, *yaml.Node, *index.SpecIndex) error + GetKeyNode() *yaml.Node + GetNodes() map[int][]*yaml.Node + GetRootNode() *yaml.Node +} + +func TestComponentValueReferenceBuilders_PreserveRootRef(t *testing.T) { + ref := "./components.yaml#/shared/Thing" + keyNode, rootNode := componentValueRefNode(t, ref) + + tests := []struct { + name string + build func(*testing.T, *yaml.Node, *yaml.Node) refAwareLowModel + }{ + { + name: "Callback", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &Callback{}) + }, + }, + { + name: "Header", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &Header{}) + }, + }, + { + name: "Link", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &Link{}) + }, + }, + { + name: "Parameter", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &Parameter{}) + }, + }, + { + name: "PathItem", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &PathItem{}) + }, + }, + { + name: "RequestBody", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &RequestBody{}) + }, + }, + { + name: "Response", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &Response{}) + }, + }, + { + name: "SecurityScheme", + build: func(t *testing.T, keyNode, rootNode *yaml.Node) refAwareLowModel { + return buildRootRefModel(t, keyNode, rootNode, &SecurityScheme{}) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + model := test.build(t, keyNode, rootNode) + + assert.True(t, model.IsReference()) + assert.Equal(t, ref, model.GetReference()) + assert.Same(t, rootNode, model.GetReferenceNode()) + assert.Same(t, rootNode, model.GetRootNode()) + assert.Same(t, keyNode, model.GetKeyNode()) + }) + } +} + +func TestScalarRootBuilders_RetainScalarNode(t *testing.T) { + rootNode := scalarRootNode(t, "scalar-root") + + tests := []struct { + name string + build func(*testing.T, *yaml.Node) scalarRootLowModel + }{ + { + name: "Link", + build: func(t *testing.T, rootNode *yaml.Node) scalarRootLowModel { + return buildScalarRootModel(t, rootNode, &Link{}) + }, + }, + { + name: "MediaType", + build: func(t *testing.T, rootNode *yaml.Node) scalarRootLowModel { + return buildScalarRootModel(t, rootNode, &MediaType{}) + }, + }, + { + name: "Parameter", + build: func(t *testing.T, rootNode *yaml.Node) scalarRootLowModel { + return buildScalarRootModel(t, rootNode, &Parameter{}) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + model := test.build(t, rootNode) + nodes := model.GetNodes() + + assert.Same(t, rootNode, model.GetRootNode()) + assert.Nil(t, model.GetKeyNode()) + require.Len(t, nodes[rootNode.Line], 1) + assert.Same(t, rootNode, nodes[rootNode.Line][0]) + assert.Equal(t, "scalar-root", nodes[rootNode.Line][0].Value) + }) + } +} + +func TestPathItem_Build_IgnoresUnknownScalarFields(t *testing.T) { + yml := `summary: supported metadata +purge: disabled +get: + description: supported operation` + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &root)) + idx := index.NewSpecIndex(&root) + rootNode := root.Content[0] + + var pathItem PathItem + require.NoError(t, low.BuildModel(rootNode, &pathItem)) + require.NoError(t, pathItem.Build(context.Background(), nil, rootNode, idx)) + + require.NotNil(t, pathItem.Get.Value) + assert.Equal(t, "supported operation", pathItem.Get.Value.Description.Value) + assert.Nil(t, pathItem.AdditionalOperations.Value) +} + +func componentValueRefNode(t *testing.T, ref string) (*yaml.Node, *yaml.Node) { + t.Helper() + + var root yaml.Node + yml := "thing:\n $ref: '" + ref + "'\n" + require.NoError(t, yaml.Unmarshal([]byte(yml), &root)) + require.NotEmpty(t, root.Content) + require.Len(t, root.Content[0].Content, 2) + return root.Content[0].Content[0], root.Content[0].Content[1] +} + +func scalarRootNode(t *testing.T, value string) *yaml.Node { + t.Helper() + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(value), &root)) + require.NotEmpty(t, root.Content) + return root.Content[0] +} + +func buildRootRefModel[T refAwareLowModel](t *testing.T, keyNode, rootNode *yaml.Node, model T) T { + t.Helper() + + idx := index.NewSpecIndexWithConfig(rootNode, &index.SpecIndexConfig{SkipExternalRefResolution: true}) + require.NoError(t, low.BuildModel(rootNode, model)) + require.NoError(t, model.Build(context.Background(), keyNode, rootNode, idx)) + return model +} + +func buildScalarRootModel[T scalarRootLowModel](t *testing.T, rootNode *yaml.Node, model T) T { + t.Helper() + + require.NoError(t, low.BuildModel(rootNode, model)) + require.NoError(t, model.Build(context.Background(), nil, rootNode, nil)) + return model +} diff --git a/datamodel/low/v3/components.go b/datamodel/low/v3/components.go index 2f792c2d..ce6dbdb0 100644 --- a/datamodel/low/v3/components.go +++ b/datamodel/low/v3/components.go @@ -5,6 +5,7 @@ package v3 import ( "context" + "errors" "fmt" "hash/maphash" "reflect" @@ -50,6 +51,7 @@ type Components struct { type componentBuildResult[T any] struct { key low.KeyReference[string] value low.ValueReference[T] + err error } type componentInput struct { @@ -302,14 +304,55 @@ func extractComponentValues[T low.Buildable[N], N any](ctx context.Context, labe translateFunc := func(_ int, value componentInput) (componentBuildResult[T], error) { var n T = new(N) currentLabel := value.currentLabel - node := value.node + node := utils.NodeAlias(value.node) + foundIndex := idx + foundContext := ctx + var localCircErr error + var refNode *yaml.Node + var referenceValue string + _, isSchemaProxy := any(n).(*base.SchemaProxy) + + if h, _, rv := utils.IsNodeRefValue(node); h && rv != "" && !isSchemaProxy && foundIndex != nil { + ref, fIdx, err, nCtx := low.LocateRefNodeWithContext(foundContext, node, foundIndex) + if ref != nil { + refNode = node + node = ref + referenceValue = rv + if fIdx != nil { + foundIndex = fIdx + } + foundContext = nCtx + if err != nil { + localCircErr = err + } + } else if errors.Is(err, low.ErrExternalRefSkipped) { + low.SetReference(n, rv, node) + v := low.ValueReference[T]{ + Value: n, + ValueNode: node, + } + v.SetReference(rv, node) + return componentBuildResult[T]{ + key: low.KeyReference[string]{ + KeyNode: currentLabel, + Value: currentLabel.Value, + }, + value: v, + }, nil + } else if err != nil { + return componentBuildResult[T]{}, fmt.Errorf("component build failed: reference cannot be found: %s", err.Error()) + } + } // build. _ = low.BuildModel(node, n) - err := n.Build(ctx, currentLabel, node, idx) + err := n.Build(foundContext, currentLabel, node, foundIndex) if err != nil { return componentBuildResult[T]{}, err } + if referenceValue != "" { + low.SetReference(n, referenceValue, refNode) + } nType := reflect.TypeOf(n) nValue := reflect.ValueOf(n) @@ -333,18 +376,27 @@ func extractComponentValues[T low.Buildable[N], N any](ctx context.Context, labe } } + valueRef := low.ValueReference[T]{ + Value: n, + ValueNode: finalValueNode, // use transformed node if available + } + if referenceValue != "" { + valueRef.SetReference(referenceValue, refNode) + } return componentBuildResult[T]{ key: low.KeyReference[string]{ KeyNode: currentLabel, Value: currentLabel.Value, }, - value: low.ValueReference[T]{ - Value: n, - ValueNode: finalValueNode, // use transformed node if available - }, + value: valueRef, + err: localCircErr, }, nil } + var circError error err := datamodel.TranslateSliceParallel(inputs, translateFunc, func(result componentBuildResult[T]) error { + if result.err != nil { + circError = result.err + } componentValues.Set(result.key, result.value) return nil }) @@ -357,5 +409,8 @@ func extractComponentValues[T low.Buildable[N], N any](ctx context.Context, labe ValueNode: nodeValue, Value: componentValues, } + if circError != nil && (idx == nil || !idx.AllowCircularReferenceResolving()) { + return results, circError + } return results, nil } diff --git a/datamodel/low/v3/components_test.go b/datamodel/low/v3/components_test.go index 93c720ff..1e2981db 100644 --- a/datamodel/low/v3/components_test.go +++ b/datamodel/low/v3/components_test.go @@ -6,15 +6,28 @@ package v3 import ( "context" "fmt" + "sync" "testing" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" "go.yaml.in/yaml/v4" ) +type componentRefCoverageBuildable struct { + reference low.Reference + *low.Reference +} + +func (c *componentRefCoverageBuildable) Build(context.Context, *yaml.Node, *yaml.Node, *index.SpecIndex) error { + c.reference = low.Reference{} + c.Reference = &c.reference + return nil +} + var testComponentsYaml = ` x-pizza: crispy schemas: @@ -192,6 +205,85 @@ func TestComponents_Build_ParameterFail(t *testing.T) { assert.Error(t, err) } +func TestComponents_Build_ComponentValueRefSkipExternal(t *testing.T) { + low.ClearHashCache() + yml := ` + parameters: + ExternalParam: + $ref: './models/params.yaml#/ExternalParam'` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + cfg := index.CreateOpenAPIIndexConfig() + cfg.SkipExternalRefResolution = true + idx := index.NewSpecIndexWithConfig(&idxNode, cfg) + + var n Components + err := low.BuildModel(&idxNode, &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], idx) + assert.NoError(t, err) + + param := n.FindParameter("ExternalParam") + if assert.NotNil(t, param) { + assert.True(t, param.IsReference()) + assert.Equal(t, "./models/params.yaml#/ExternalParam", param.GetReference()) + assert.True(t, param.Value.IsReference()) + assert.Equal(t, "./models/params.yaml#/ExternalParam", param.Value.GetReference()) + } +} + +func TestComponents_Build_ComponentValueRefCircular(t *testing.T) { + low.ClearHashCache() + yml := `openapi: 3.0.0 +components: + parameters: + First: + $ref: '#/components/parameters/First'` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + idx := index.NewSpecIndex(&idxNode) + _, _, compNode := utils.FindKeyNodeFullTop(ComponentsLabel, idxNode.Content[0].Content) + + var n Components + err := low.BuildModel(compNode, &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), compNode, idx) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "circular reference") + } +} + +func TestExtractComponentValues_ComponentValueRefCircularError(t *testing.T) { + low.ClearHashCache() + yml := `openapi: 3.0.0 +components: + parameters: + First: + $ref: '#/components/parameters/First'` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + idx := index.NewSpecIndex(&idxNode) + _, _, compNode := utils.FindKeyNodeFullTop(ComponentsLabel, idxNode.Content[0].Content) + + var nodeStore sync.Map + components := &Components{} + components.Nodes = &nodeStore + + result, err := extractComponentValues[*componentRefCoverageBuildable](context.Background(), ParametersLabel, compNode, idx, components) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "circular reference") + } + assert.NotNil(t, result.Value) +} + // Test parse failure among many parameters. // This stresses `TranslatePipeline`'s error handling. func TestComponents_Build_ParameterFail_Many(t *testing.T) {