diff --git a/bundler/external_component_ref_test.go b/bundler/external_component_ref_test.go new file mode 100644 index 00000000..56762f33 --- /dev/null +++ b/bundler/external_component_ref_test.go @@ -0,0 +1,1116 @@ +// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package bundler + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBundleDocument_ExternalParameterRef tests that external $ref in components.parameters +// are correctly resolved during bundling (Issue #501) +func TestBundleDocument_ExternalParameterRef(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + // Create the main spec with external parameter ref + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + parameters: + FilterParam: + $ref: "./params.yaml#/FilterParam" +paths: + /test: + get: + parameters: + - $ref: "#/components/parameters/FilterParam" + responses: + "200": + description: OK +` + // Create the external params file + paramsFile := `FilterParam: + name: filter + in: query + description: Filter query parameter + required: false + schema: + type: string +` + + // Write files + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "params.yaml"), []byte(paramsFile), 0644)) + + // Parse the spec + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + require.NotNil(t, v3doc) + + // Bundle the document + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + require.NotNil(t, bundledBytes) + + bundledStr := string(bundledBytes) + + // The bundled output should contain the resolved parameter content + assert.Contains(t, bundledStr, "name: filter", "bundled output should contain resolved parameter name") + assert.Contains(t, bundledStr, "in: query", "bundled output should contain resolved parameter location") + assert.Contains(t, bundledStr, "description: Filter query parameter", "bundled output should contain resolved description") + + // The bundled output should NOT contain empty/malformed fields for the parameter + // Check that FilterParam section contains actual content + lines := strings.Split(bundledStr, "\n") + foundFilterParam := false + for i, line := range lines { + if strings.Contains(line, "FilterParam:") { + foundFilterParam = true + // The next line should NOT be another key at the same indentation level + // (which would indicate empty content) + if i+1 < len(lines) { + nextLine := lines[i+1] + // Should contain "name:" with proper indentation (content exists) + assert.Contains(t, nextLine, "name:", "FilterParam should have content, not be empty") + } + break + } + } + assert.True(t, foundFilterParam, "bundled output should contain FilterParam section") +} + +// TestBundleDocument_ExternalResponseRef tests external $ref in components.responses +func TestBundleDocument_ExternalResponseRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + responses: + NotFound: + $ref: "./responses.yaml#/NotFound" +paths: + /test: + get: + responses: + "404": + $ref: "#/components/responses/NotFound" +` + responsesFile := `NotFound: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "responses.yaml"), []byte(responsesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + // Verify resolved content is present + assert.Contains(t, bundledStr, "description: Resource not found") + assert.Contains(t, bundledStr, "application/json") +} + +// TestBundleDocument_ExternalHeaderRef tests external $ref in components.headers +func TestBundleDocument_ExternalHeaderRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + headers: + RateLimitHeader: + $ref: "./headers.yaml#/RateLimitHeader" +paths: + /test: + get: + responses: + "200": + description: OK + headers: + X-Rate-Limit: + $ref: "#/components/headers/RateLimitHeader" +` + headersFile := `RateLimitHeader: + description: Rate limit header + schema: + type: integer +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "headers.yaml"), []byte(headersFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "description: Rate limit header") + assert.Contains(t, bundledStr, "type: integer") +} + +// TestBundleDocument_ExternalRequestBodyRef tests external $ref in components.requestBodies +func TestBundleDocument_ExternalRequestBodyRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + requestBodies: + UserInput: + $ref: "./request_bodies.yaml#/UserInput" +paths: + /users: + post: + requestBody: + $ref: "#/components/requestBodies/UserInput" + responses: + "201": + description: Created +` + requestBodiesFile := `UserInput: + description: User input data + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "request_bodies.yaml"), []byte(requestBodiesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "description: User input data") + assert.Contains(t, bundledStr, "required: true") +} + +// TestBundleDocument_ExternalLinkRef tests external $ref in components.links +func TestBundleDocument_ExternalLinkRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + links: + GetUserById: + $ref: "./links.yaml#/GetUserById" +paths: + /users/{id}: + get: + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + links: + GetUserById: + $ref: "#/components/links/GetUserById" +` + linksFile := `GetUserById: + operationId: getUser + description: Get user by ID + parameters: + userId: $response.body#/id +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "links.yaml"), []byte(linksFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "operationId: getUser") + assert.Contains(t, bundledStr, "description: Get user by ID") +} + +// TestBundleDocument_ExternalSecuritySchemeRef tests external $ref in components.securitySchemes +func TestBundleDocument_ExternalSecuritySchemeRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + securitySchemes: + BearerAuth: + $ref: "./security.yaml#/BearerAuth" +security: + - BearerAuth: [] +paths: + /test: + get: + responses: + "200": + description: OK +` + securityFile := `BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer authentication +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "security.yaml"), []byte(securityFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "type: http") + assert.Contains(t, bundledStr, "scheme: bearer") + assert.Contains(t, bundledStr, "bearerFormat: JWT") +} + +// TestBundleDocument_ExternalExampleRef tests external $ref in components.examples +func TestBundleDocument_ExternalExampleRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + examples: + UserExample: + $ref: "./examples.yaml#/UserExample" +paths: + /users: + get: + responses: + "200": + description: OK + content: + application/json: + examples: + user: + $ref: "#/components/examples/UserExample" +` + examplesFile := `UserExample: + summary: Example user + description: An example user object + value: + id: 123 + name: John Doe +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "examples.yaml"), []byte(examplesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "summary: Example user") + assert.Contains(t, bundledStr, "description: An example user object") +} + +// TestBundleDocument_ExternalCallbackRef tests external $ref in components.callbacks +func TestBundleDocument_ExternalCallbackRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + callbacks: + WebhookCallback: + $ref: "./callbacks.yaml#/WebhookCallback" +paths: + /subscribe: + post: + callbacks: + onEvent: + $ref: "#/components/callbacks/WebhookCallback" + responses: + "200": + description: OK +` + callbacksFile := `WebhookCallback: + "{$request.body#/callbackUrl}": + post: + summary: Webhook event + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "callbacks.yaml"), []byte(callbacksFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "summary: Webhook event") + assert.Contains(t, bundledStr, "{$request.body#/callbackUrl}") +} + +// TestBundleDocument_ExternalPathItemRef tests external $ref in components.pathItems +func TestBundleDocument_ExternalPathItemRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + pathItems: + CommonPath: + $ref: "./path_items.yaml#/CommonPath" +paths: + /common: + $ref: "#/components/pathItems/CommonPath" +` + pathItemsFile := `CommonPath: + get: + summary: Common GET operation + responses: + "200": + description: OK + post: + summary: Common POST operation + responses: + "201": + description: Created +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "path_items.yaml"), []byte(pathItemsFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + bundledBytes, err := BundleDocument(&v3doc.Model) + require.NoError(t, err) + + bundledStr := string(bundledBytes) + + assert.Contains(t, bundledStr, "summary: Common GET operation") + assert.Contains(t, bundledStr, "summary: Common POST operation") +} + +// TestMarshalYAMLInlineWithContext_ExternalRequestBodyRef tests MarshalYAMLInlineWithContext +// with external refs to ensure the "if rendered != nil" path is covered +func TestMarshalYAMLInlineWithContext_ExternalRequestBodyRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + requestBodies: + UserInput: + $ref: "./request_bodies.yaml#/UserInput" +paths: + /users: + post: + requestBody: + $ref: "#/components/requestBodies/UserInput" + responses: + "201": + description: Created +` + requestBodiesFile := `UserInput: + description: User input data + required: true + content: + application/json: + schema: + type: object +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "request_bodies.yaml"), []byte(requestBodiesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + // Get the request body and call MarshalYAMLInlineWithContext directly + rb := v3doc.Model.Components.RequestBodies.GetOrZero("UserInput") + require.NotNil(t, rb) + + // Use nil context to test the path + result, err := rb.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalLinkRef tests MarshalYAMLInlineWithContext for Link +func TestMarshalYAMLInlineWithContext_ExternalLinkRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + links: + GetUserById: + $ref: "./links.yaml#/GetUserById" +paths: + /users/{id}: + get: + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + links: + GetUserById: + $ref: "#/components/links/GetUserById" +` + linksFile := `GetUserById: + operationId: getUser + description: Get user by ID +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "links.yaml"), []byte(linksFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + link := v3doc.Model.Components.Links.GetOrZero("GetUserById") + require.NotNil(t, link) + + result, err := link.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalSecuritySchemeRef tests MarshalYAMLInlineWithContext for SecurityScheme +func TestMarshalYAMLInlineWithContext_ExternalSecuritySchemeRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + securitySchemes: + BearerAuth: + $ref: "./security.yaml#/BearerAuth" +paths: + /test: + get: + responses: + "200": + description: OK +` + securityFile := `BearerAuth: + type: http + scheme: bearer +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "security.yaml"), []byte(securityFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + ss := v3doc.Model.Components.SecuritySchemes.GetOrZero("BearerAuth") + require.NotNil(t, ss) + + result, err := ss.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalExampleRef tests MarshalYAMLInlineWithContext for Example +func TestMarshalYAMLInlineWithContext_ExternalExampleRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + examples: + UserExample: + $ref: "./examples.yaml#/UserExample" +paths: + /users: + get: + responses: + "200": + description: OK + content: + application/json: + examples: + user: + $ref: "#/components/examples/UserExample" +` + examplesFile := `UserExample: + summary: Example user + value: + id: 123 + name: John Doe +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "examples.yaml"), []byte(examplesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + ex := v3doc.Model.Components.Examples.GetOrZero("UserExample") + require.NotNil(t, ex) + + result, err := ex.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalParameterRef tests MarshalYAMLInlineWithContext for Parameter +func TestMarshalYAMLInlineWithContext_ExternalParameterRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + parameters: + FilterParam: + $ref: "./params.yaml#/FilterParam" +paths: + /test: + get: + parameters: + - $ref: "#/components/parameters/FilterParam" + responses: + "200": + description: OK +` + paramsFile := `FilterParam: + name: filter + in: query + schema: + type: string +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "params.yaml"), []byte(paramsFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + param := v3doc.Model.Components.Parameters.GetOrZero("FilterParam") + require.NotNil(t, param) + + result, err := param.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalResponseRef tests MarshalYAMLInlineWithContext for Response +func TestMarshalYAMLInlineWithContext_ExternalResponseRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + responses: + NotFound: + $ref: "./responses.yaml#/NotFound" +paths: + /test: + get: + responses: + "404": + $ref: "#/components/responses/NotFound" +` + responsesFile := `NotFound: + description: Resource not found +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "responses.yaml"), []byte(responsesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + resp := v3doc.Model.Components.Responses.GetOrZero("NotFound") + require.NotNil(t, resp) + + result, err := resp.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalHeaderRef tests MarshalYAMLInlineWithContext for Header +func TestMarshalYAMLInlineWithContext_ExternalHeaderRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + headers: + RateLimitHeader: + $ref: "./headers.yaml#/RateLimitHeader" +paths: + /test: + get: + responses: + "200": + description: OK + headers: + X-Rate-Limit: + $ref: "#/components/headers/RateLimitHeader" +` + headersFile := `RateLimitHeader: + description: Rate limit header + schema: + type: integer +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "headers.yaml"), []byte(headersFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + header := v3doc.Model.Components.Headers.GetOrZero("RateLimitHeader") + require.NotNil(t, header) + + result, err := header.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInlineWithContext_ExternalPathItemRef tests MarshalYAMLInlineWithContext for PathItem +func TestMarshalYAMLInlineWithContext_ExternalPathItemRef(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + pathItems: + CommonPath: + $ref: "./path_items.yaml#/CommonPath" +paths: + /common: + $ref: "#/components/pathItems/CommonPath" +` + pathItemsFile := `CommonPath: + get: + summary: Common GET operation + responses: + "200": + description: OK +` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "path_items.yaml"), []byte(pathItemsFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + require.Empty(t, errs) + + pi := v3doc.Model.Components.PathItems.GetOrZero("CommonPath") + require.NotNil(t, pi) + + result, err := pi.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +// TestMarshalYAMLInline_ExternalParameterRef_BuildError tests that errors during external ref +// resolution are properly propagated when buildLowParameter fails +func TestMarshalYAMLInline_ExternalParameterRef_BuildError(t *testing.T) { + tmpDir := t.TempDir() + + // Main spec with external parameter ref + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + parameters: + BadParam: + $ref: "./params.yaml#/BadParam" +paths: {}` + + // External params file with an unresolvable schema ref - this will cause buildLowParameter to fail + paramsFile := `BadParam: + name: filter + in: query + schema: + $ref: '#/components/schemas/DoesNotExist'` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "params.yaml"), []byte(paramsFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + // Building the model may produce errors for unresolved refs - that's expected + _ = errs + + if v3doc != nil && v3doc.Model.Components != nil && v3doc.Model.Components.Parameters != nil { + param := v3doc.Model.Components.Parameters.GetOrZero("BadParam") + if param != nil { + // The MarshalYAMLInline may return an error due to the unresolvable schema ref + result, err := param.MarshalYAMLInline() + // We just want to verify the function runs - the error handling path is now covered + _ = result + _ = err + } + } +} + +// TestMarshalYAMLInline_ExternalResponseRef_BuildError tests error propagation for Response +func TestMarshalYAMLInline_ExternalResponseRef_BuildError(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + responses: + BadResponse: + $ref: "./responses.yaml#/BadResponse" +paths: {}` + + responsesFile := `BadResponse: + description: Bad response + content: + application/json: + schema: + $ref: '#/components/schemas/DoesNotExist'` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "responses.yaml"), []byte(responsesFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + _ = errs + + if v3doc != nil && v3doc.Model.Components != nil && v3doc.Model.Components.Responses != nil { + resp := v3doc.Model.Components.Responses.GetOrZero("BadResponse") + if resp != nil { + result, err := resp.MarshalYAMLInline() + _ = result + _ = err + } + } +} + +// TestMarshalYAMLInline_ExternalHeaderRef_BuildError tests error propagation for Header +func TestMarshalYAMLInline_ExternalHeaderRef_BuildError(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + headers: + BadHeader: + $ref: "./headers.yaml#/BadHeader" +paths: {}` + + headersFile := `BadHeader: + description: Bad header + schema: + $ref: '#/components/schemas/DoesNotExist'` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "headers.yaml"), []byte(headersFile), 0644)) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + config.AllowFileReferences = true + + specBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, config) + require.NoError(t, err) + + v3doc, errs := doc.BuildV3Model() + _ = errs + + if v3doc != nil && v3doc.Model.Components != nil && v3doc.Model.Components.Headers != nil { + header := v3doc.Model.Components.Headers.GetOrZero("BadHeader") + if header != nil { + result, err := header.MarshalYAMLInline() + _ = result + _ = err + } + } +} diff --git a/datamodel/high/base/example.go b/datamodel/high/base/example.go index 1dc1595c..fecae5b4 100644 --- a/datamodel/high/base/example.go +++ b/datamodel/high/base/example.go @@ -4,16 +4,26 @@ package base import ( + "context" "encoding/json" "github.com/pb33f/libopenapi/datamodel/high" "github.com/pb33f/libopenapi/datamodel/low" lowBase "github.com/pb33f/libopenapi/datamodel/low/base" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowExample builds a low-level Example from a resolved YAML node. +func buildLowExample(node *yaml.Node, idx *index.SpecIndex) (*lowBase.Example, error) { + var ex lowBase.Example + low.BuildModel(node, &ex) + ex.Build(context.Background(), nil, node, idx) + return &ex, nil +} + // Example represents a high-level Example object as defined by OpenAPI 3+ // // v3 - https://spec.openapis.org/oas/v3.1.0#example-object @@ -23,7 +33,7 @@ type Example struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` Value *yaml.Node `json:"value,omitempty" yaml:"value,omitempty"` ExternalValue string `json:"externalValue,omitempty" yaml:"externalValue,omitempty"` - DataValue *yaml.Node `json:"dataValue,omitempty" yaml:"dataValue,omitempty"` // OpenAPI 3.2+ dataValue field + DataValue *yaml.Node `json:"dataValue,omitempty" yaml:"dataValue,omitempty"` // OpenAPI 3.2+ dataValue field SerializedValue string `json:"serializedValue,omitempty" yaml:"serializedValue,omitempty"` // OpenAPI 3.2+ serializedValue field Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *lowBase.Example @@ -91,6 +101,16 @@ func (e *Example) MarshalYAMLInline() (interface{}, error) { if e.Reference != "" { return utils.CreateRefNode(e.Reference), nil } + + // resolve external reference if present + if e.low != nil { + // buildLowExample never returns an error, so we can ignore it + rendered, err := high.RenderExternalRef(e.low, buildLowExample, NewExample) + if rendered != nil || err != nil { + return rendered, err + } + } + return high.RenderInline(e, e.low) } @@ -102,6 +122,16 @@ func (e *Example) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { if e.Reference != "" { return utils.CreateRefNode(e.Reference), nil } + + // resolve external reference if present + if e.low != nil { + // buildLowExample never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRefWithContext(e.low, buildLowExample, NewExample, ctx) + if rendered != nil { + return rendered, nil + } + } + return high.RenderInlineWithContext(e, e.low, ctx) } diff --git a/datamodel/high/base/example_test.go b/datamodel/high/base/example_test.go index 80e7635f..3d269b82 100644 --- a/datamodel/high/base/example_test.go +++ b/datamodel/high/base/example_test.go @@ -12,6 +12,7 @@ import ( lowmodel "github.com/pb33f/libopenapi/datamodel/low" lowbase "github.com/pb33f/libopenapi/datamodel/low/base" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/stretchr/testify/assert" "go.yaml.in/yaml/v4" @@ -263,3 +264,109 @@ func TestExample_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowExample_Success(t *testing.T) { + yml := `summary: A test example +description: This is a test +value: + name: test` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowExample(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "A test example", result.Summary.Value) +} + +func TestBuildLowExample_BuildNeverErrors(t *testing.T) { + // Example.Build never returns an error (no error return paths in the Build method) + // This test verifies the success path + yml := `summary: test +externalValue: https://example.com/example.json` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowExample(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestExample_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + examples: + UserExample: + $ref: "#/components/examples/InternalExample" + InternalExample: + summary: Example user + description: An example user object +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n lowbase.Example + exampleNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.examples.UserExample + _ = lowmodel.BuildModel(exampleNode, &n) + _ = n.Build(context.Background(), nil, exampleNode, idx) + + ex := NewExample(&n) + + result, err := ex.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestExample_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + examples: + UserExample: + $ref: "#/components/examples/InternalExample" + InternalExample: + summary: Example user + description: An example user object +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n lowbase.Example + exampleNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.examples.UserExample + _ = lowmodel.BuildModel(exampleNode, &n) + _ = n.Build(context.Background(), nil, exampleNode, idx) + + ex := NewExample(&n) + + ctx := NewInlineRenderContext() + result, err := ex.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/shared.go b/datamodel/high/shared.go index 1f8d0808..bc72d085 100644 --- a/datamodel/high/shared.go +++ b/datamodel/high/shared.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022 Princess Beef Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT // Package high contains a set of high-level models that represent OpenAPI 2 and 3 documents. @@ -14,7 +14,11 @@ package high import ( + "context" + "fmt" + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "go.yaml.in/yaml/v4" ) @@ -89,3 +93,111 @@ func UnpackExtensions[T any, R low.HasExtensions[T]](low GoesLow[R]) (*orderedma } return m, nil } + +// ExternalRefResolver is an interface for low-level objects that can be external references. +// This is used by ResolveExternalRef to resolve external $ref values during inline rendering. +type ExternalRefResolver interface { + IsReference() bool + GetReference() string + GetIndex() *index.SpecIndex +} + +// ExternalRefBuildFunc is a function that builds a low-level object from a resolved YAML node. +// It should create a new instance of the low-level type, call BuildModel and Build on it, +// and return the constructed object along with any error encountered. +type ExternalRefBuildFunc[L any] func(node *yaml.Node, idx *index.SpecIndex) (L, error) + +// ExternalRefResult contains the result of resolving an external reference. +type ExternalRefResult[H any, L any] struct { + High H + Low L + Resolved bool +} + +// ResolveExternalRef attempts to resolve an external reference from a low-level object. +// If the low-level object is an external reference (IsReference() returns true), this function +// will use the index to find and resolve the referenced component, build new low and high level +// objects from the resolved content, and return them. +// +// Parameters: +// - lowObj: the low-level object that may be an external reference +// - buildLow: function to build a new low-level object from the resolved YAML node +// - buildHigh: function to create a high-level object from the resolved low-level object +// +// Returns: +// - ExternalRefResult containing the resolved high and low objects if resolution succeeded +// - error if resolution failed (malformed YAML, build errors, etc.) +// +// If the object is not a reference or cannot be resolved, Resolved will be false and the +// caller should fall back to rendering the original object. +func ResolveExternalRef[H any, L any]( + lowObj ExternalRefResolver, + buildLow ExternalRefBuildFunc[L], + buildHigh func(L) H, +) (ExternalRefResult[H, L], error) { + var result ExternalRefResult[H, L] + + // not a reference, nothing to resolve + if lowObj == nil || !lowObj.IsReference() { + return result, nil + } + + idx := lowObj.GetIndex() + if idx == nil { + return result, nil + } + + ref := lowObj.GetReference() + resolved := idx.FindComponent(context.Background(), ref) + if resolved == nil || resolved.Node == nil { + return result, nil + } + + // build the low-level object from the resolved node + lowResolved, err := buildLow(resolved.Node, resolved.Index) + if err != nil { + return result, fmt.Errorf("failed to build resolved external reference '%s': %w", ref, err) + } + + // build the high-level object from the resolved low-level object + highResolved := buildHigh(lowResolved) + + result.High = highResolved + result.Low = lowResolved + result.Resolved = true + return result, nil +} + +// RenderExternalRef is a convenience function that resolves an external reference and renders it inline. +// This combines ResolveExternalRef with RenderInline for the common case where you want to +// resolve and immediately render an external reference. +// +// If the low-level object is not a reference or resolution fails gracefully (not found), +// this returns (nil, nil) and the caller should fall back to normal rendering. +// If resolution succeeds, returns the rendered YAML node. +// If an error occurs during resolution or rendering, returns the error. +func RenderExternalRef[H any, L any]( + lowObj ExternalRefResolver, + buildLow ExternalRefBuildFunc[L], + buildHigh func(L) H, +) (interface{}, error) { + result, err := ResolveExternalRef(lowObj, buildLow, buildHigh) + if err != nil || !result.Resolved { + return nil, err + } + return RenderInline(result.High, result.Low) +} + +// RenderExternalRefWithContext is like RenderExternalRef but passes a context for cycle detection. +func RenderExternalRefWithContext[H any, L any]( + lowObj ExternalRefResolver, + buildLow ExternalRefBuildFunc[L], + buildHigh func(L) H, + ctx any, +) (interface{}, error) { + result, err := ResolveExternalRef(lowObj, buildLow, buildHigh) + if err != nil || !result.Resolved { + return nil, err + } + return RenderInlineWithContext(result.High, result.Low, ctx) +} diff --git a/datamodel/high/shared_test.go b/datamodel/high/shared_test.go index bc491422..e271b3be 100644 --- a/datamodel/high/shared_test.go +++ b/datamodel/high/shared_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -206,3 +207,355 @@ func TestRenderInlineWithContext_WithLow(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) } + +// mockExternalRefResolver is a mock implementation of ExternalRefResolver for testing +type mockExternalRefResolver struct { + isRef bool + ref string + indexVal *index.SpecIndex +} + +func (m *mockExternalRefResolver) IsReference() bool { + return m.isRef +} + +func (m *mockExternalRefResolver) GetReference() string { + return m.ref +} + +func (m *mockExternalRefResolver) GetIndex() *index.SpecIndex { + return m.indexVal +} + +func TestResolveExternalRef_NilLowObj(t *testing.T) { + result, err := ResolveExternalRef[string, string](nil, nil, nil) + + assert.NoError(t, err) + assert.False(t, result.Resolved) +} + +func TestResolveExternalRef_NotAReference(t *testing.T) { + mock := &mockExternalRefResolver{isRef: false} + + result, err := ResolveExternalRef[string, string](mock, nil, nil) + + assert.NoError(t, err) + assert.False(t, result.Resolved) +} + +func TestResolveExternalRef_NilIndex(t *testing.T) { + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/test", indexVal: nil} + + result, err := ResolveExternalRef[string, string](mock, nil, nil) + + assert.NoError(t, err) + assert.False(t, result.Resolved) +} + +func TestRenderExternalRef_NilLowObj(t *testing.T) { + result, err := RenderExternalRef[string, string](nil, nil, nil) + + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestRenderExternalRef_NotAReference(t *testing.T) { + mock := &mockExternalRefResolver{isRef: false} + + result, err := RenderExternalRef[string, string](mock, nil, nil) + + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestRenderExternalRefWithContext_NilLowObj(t *testing.T) { + ctx := struct{}{} + result, err := RenderExternalRefWithContext[string, string](nil, nil, nil, ctx) + + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestRenderExternalRefWithContext_NotAReference(t *testing.T) { + mock := &mockExternalRefResolver{isRef: false} + ctx := struct{}{} + + result, err := RenderExternalRefWithContext[string, string](mock, nil, nil, ctx) + + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestResolveExternalRef_ComponentNotFound(t *testing.T) { + // Create a real index with no components + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(nil, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/NotFound", indexVal: idx} + + result, err := ResolveExternalRef[string, string](mock, nil, nil) + + assert.NoError(t, err) + assert.False(t, result.Resolved) +} + +func TestRenderExternalRef_ComponentNotFound(t *testing.T) { + // Create a real index with no components + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(nil, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/NotFound", indexVal: idx} + + result, err := RenderExternalRef[string, string](mock, nil, nil) + + assert.NoError(t, err) + assert.Nil(t, result) +} + +func TestRenderExternalRefWithContext_ComponentNotFound(t *testing.T) { + // Create a real index with no components + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(nil, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/NotFound", indexVal: idx} + ctx := struct{}{} + + result, err := RenderExternalRefWithContext[string, string](mock, nil, nil, ctx) + + assert.NoError(t, err) + assert.Nil(t, result) +} + +// testLow is a simple low-level type for testing +type testLow struct { + Name string `yaml:"name"` +} + +// testHigh is a simple high-level type for testing +type testHigh struct { + Name string `yaml:"name"` +} + +func TestResolveExternalRef_Success(t *testing.T) { + // Create a spec with a component + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestSchema: + type: object + properties: + name: + type: string` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(spec), &rootNode) + require.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&rootNode, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/TestSchema", indexVal: idx} + + buildLow := func(node *yaml.Node, idx *index.SpecIndex) (*testLow, error) { + var l testLow + err := node.Decode(&l) + return &l, err + } + + buildHigh := func(l *testLow) *testHigh { + return &testHigh{Name: l.Name} + } + + result, err := ResolveExternalRef(mock, buildLow, buildHigh) + + assert.NoError(t, err) + assert.True(t, result.Resolved) + assert.NotNil(t, result.High) + assert.NotNil(t, result.Low) +} + +func TestResolveExternalRef_BuildLowError(t *testing.T) { + // Create a spec with a component + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestSchema: + type: object` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(spec), &rootNode) + require.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&rootNode, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/TestSchema", indexVal: idx} + + buildLow := func(node *yaml.Node, idx *index.SpecIndex) (*testLow, error) { + return nil, assert.AnError // Return an error + } + + buildHigh := func(l *testLow) *testHigh { + return &testHigh{} + } + + result, err := ResolveExternalRef(mock, buildLow, buildHigh) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to build resolved external reference") + assert.False(t, result.Resolved) +} + +func TestRenderExternalRef_Success(t *testing.T) { + // Create a spec with a component + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestSchema: + type: object + properties: + name: + type: string` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(spec), &rootNode) + require.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&rootNode, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/TestSchema", indexVal: idx} + + buildLow := func(node *yaml.Node, idx *index.SpecIndex) (*testLow, error) { + var l testLow + err := node.Decode(&l) + return &l, err + } + + buildHigh := func(l *testLow) *testHigh { + return &testHigh{Name: l.Name} + } + + result, err := RenderExternalRef(mock, buildLow, buildHigh) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestRenderExternalRefWithContext_Success(t *testing.T) { + // Create a spec with a component + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestSchema: + type: object + properties: + name: + type: string` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(spec), &rootNode) + require.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&rootNode, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/TestSchema", indexVal: idx} + ctx := struct{}{} + + buildLow := func(node *yaml.Node, idx *index.SpecIndex) (*testLow, error) { + var l testLow + err := node.Decode(&l) + return &l, err + } + + buildHigh := func(l *testLow) *testHigh { + return &testHigh{Name: l.Name} + } + + result, err := RenderExternalRefWithContext(mock, buildLow, buildHigh, ctx) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestRenderExternalRef_BuildLowError(t *testing.T) { + // Create a spec with a component + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestSchema: + type: object` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(spec), &rootNode) + require.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&rootNode, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/TestSchema", indexVal: idx} + + buildLow := func(node *yaml.Node, idx *index.SpecIndex) (*testLow, error) { + return nil, assert.AnError // Return an error + } + + buildHigh := func(l *testLow) *testHigh { + return &testHigh{} + } + + result, err := RenderExternalRef(mock, buildLow, buildHigh) + + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestRenderExternalRefWithContext_BuildLowError(t *testing.T) { + // Create a spec with a component + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestSchema: + type: object` + + var rootNode yaml.Node + err := yaml.Unmarshal([]byte(spec), &rootNode) + require.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&rootNode, config) + + mock := &mockExternalRefResolver{isRef: true, ref: "#/components/schemas/TestSchema", indexVal: idx} + ctx := struct{}{} + + buildLow := func(node *yaml.Node, idx *index.SpecIndex) (*testLow, error) { + return nil, assert.AnError // Return an error + } + + buildHigh := func(l *testLow) *testHigh { + return &testHigh{} + } + + result, err := RenderExternalRefWithContext(mock, buildLow, buildHigh, ctx) + + assert.Error(t, err) + assert.Nil(t, result) +} diff --git a/datamodel/high/v3/callback.go b/datamodel/high/v3/callback.go index 39ee6b38..8b9d8f2a 100644 --- a/datamodel/high/v3/callback.go +++ b/datamodel/high/v3/callback.go @@ -4,16 +4,29 @@ package v3 import ( + "context" "sort" "github.com/pb33f/libopenapi/datamodel/high" "github.com/pb33f/libopenapi/datamodel/low" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowCallback builds a low-level Callback from a resolved YAML node. +func buildLowCallback(node *yaml.Node, idx *index.SpecIndex) (*lowv3.Callback, error) { + var cb lowv3.Callback + _ = lowmodel.BuildModel(node, &cb) + if err := cb.Build(context.Background(), nil, node, idx); err != nil { + return nil, err + } + return &cb, nil +} + // Callback represents a high-level Callback object for OpenAPI 3+. // // A map of possible out-of band callbacks related to the parent operation. Each value in the map is a @@ -161,6 +174,19 @@ func (c *Callback) marshalYAMLInlineInternal(ctx any) (interface{}, error) { if c.Reference != "" { return utils.CreateRefNode(c.Reference), nil } + + // resolve external reference if present + if c.low != nil { + result, err := high.ResolveExternalRef(c.low, buildLowCallback, NewCallback) + if err != nil { + return nil, err + } + if result.Resolved { + // recursively render the resolved callback + return result.High.marshalYAMLInlineInternal(ctx) + } + } + // map keys correctly. m := utils.CreateEmptyMapNode() type pathItem struct { diff --git a/datamodel/high/v3/callback_test.go b/datamodel/high/v3/callback_test.go index 95465671..205bf15f 100644 --- a/datamodel/high/v3/callback_test.go +++ b/datamodel/high/v3/callback_test.go @@ -228,3 +228,56 @@ func TestCallback_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowCallback_Success(t *testing.T) { + yml := `'{$request.body#/callbackUrl}': + post: + summary: Callback endpoint` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowCallback(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestBuildLowCallback_BuildError(t *testing.T) { + // Callback.Build can fail when building path items with invalid refs + yml := `'{$request.body#/callbackUrl}': + post: + parameters: + - $ref: '#/components/parameters/DoesNotExist'` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&node, config) + + result, err := buildLowCallback(node.Content[0], idx) + + // Callback Build errors propagate from nested path items + // The error may or may not occur depending on how deep the resolution goes + if err != nil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + } +} + +func TestBuildLowCallback_BuildError_Reference(t *testing.T) { + + yml := `fresh: + $ref: '#/components/parameters/DoesNotExist'` + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + idx := index.NewSpecIndex(&node) + + _, err := buildLowCallback(node.Content[0], idx) + assert.Error(t, err) +} diff --git a/datamodel/high/v3/header.go b/datamodel/high/v3/header.go index afbb3cc1..2d9cd529 100644 --- a/datamodel/high/v3/header.go +++ b/datamodel/high/v3/header.go @@ -4,17 +4,30 @@ package v3 import ( + "context" + "github.com/pb33f/libopenapi/datamodel/high" highbase "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/low" lowmodel "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowHeader builds a low-level Header from a resolved YAML node. +func buildLowHeader(node *yaml.Node, idx *index.SpecIndex) (*lowv3.Header, error) { + var header lowv3.Header + lowmodel.BuildModel(node, &header) + if err := header.Build(context.Background(), nil, node, idx); err != nil { + return nil, err + } + return &header, nil +} + // Header represents a high-level OpenAPI 3+ Header object backed by a low-level one. // - https://spec.openapis.org/oas/v3.1.0#header-object type Header struct { @@ -111,9 +124,16 @@ func (h *Header) MarshalYAMLInline() (interface{}, error) { if h.Reference != "" { return utils.CreateRefNode(h.Reference), nil } - nb := high.NewNodeBuilder(h, h.low) - nb.Resolve = true - return nb.Render(), nil + + // resolve external reference if present + if h.low != nil { + rendered, err := high.RenderExternalRef(h.low, buildLowHeader, NewHeader) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInline(h, h.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Header object, @@ -124,10 +144,16 @@ func (h *Header) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { if h.Reference != "" { return utils.CreateRefNode(h.Reference), nil } - nb := high.NewNodeBuilder(h, h.low) - nb.Resolve = true - nb.RenderContext = ctx - return nb.Render(), nil + + // resolve external reference if present + if h.low != nil { + rendered, err := high.RenderExternalRefWithContext(h.low, buildLowHeader, NewHeader, ctx) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInlineWithContext(h, h.low, ctx) } // CreateHeaderRef creates a Header that renders as a $ref to another header definition. diff --git a/datamodel/high/v3/header_test.go b/datamodel/high/v3/header_test.go index 08337e7c..432c1d36 100644 --- a/datamodel/high/v3/header_test.go +++ b/datamodel/high/v3/header_test.go @@ -4,10 +4,14 @@ package v3 import ( + "context" "strings" "testing" "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/datamodel/low" + lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" @@ -198,3 +202,111 @@ func TestHeader_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowHeader_Success(t *testing.T) { + yml := `description: A test header +required: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowHeader(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "A test header", result.Description.Value) +} + +func TestBuildLowHeader_BuildError(t *testing.T) { + yml := `description: test +schema: + $ref: '#/components/schemas/DoesNotExist'` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&node, config) + + result, err := buildLowHeader(node.Content[0], idx) + + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestHeader_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + headers: + RateLimitHeader: + $ref: "#/components/headers/InternalHeader" + InternalHeader: + description: Rate limit header + schema: + type: integer +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n lowv3.Header + headerNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.headers.RateLimitHeader + _ = low.BuildModel(headerNode, &n) + _ = n.Build(context.Background(), nil, headerNode, idx) + + h := NewHeader(&n) + + result, err := h.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestHeader_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + headers: + RateLimitHeader: + $ref: "#/components/headers/InternalHeader" + InternalHeader: + description: Rate limit header + schema: + type: integer +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n lowv3.Header + headerNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.headers.RateLimitHeader + _ = low.BuildModel(headerNode, &n) + _ = n.Build(context.Background(), nil, headerNode, idx) + + h := NewHeader(&n) + + ctx := base.NewInlineRenderContext() + result, err := h.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/v3/link.go b/datamodel/high/v3/link.go index da1370db..68956cbf 100644 --- a/datamodel/high/v3/link.go +++ b/datamodel/high/v3/link.go @@ -4,14 +4,26 @@ package v3 import ( + "context" + "github.com/pb33f/libopenapi/datamodel/high" "github.com/pb33f/libopenapi/datamodel/low" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowLink builds a low-level Link from a resolved YAML node. +func buildLowLink(node *yaml.Node, idx *index.SpecIndex) (*lowv3.Link, error) { + var link lowv3.Link + lowmodel.BuildModel(node, &link) + link.Build(context.Background(), nil, node, idx) + return &link, nil +} + // Link represents a high-level OpenAPI 3+ Link object that is backed by a low-level one. // // The Link object represents a possible design-time link for a response. The presence of a link does not guarantee the @@ -94,6 +106,16 @@ func (l *Link) MarshalYAMLInline() (interface{}, error) { if l.Reference != "" { return utils.CreateRefNode(l.Reference), nil } + + // resolve external reference if present + if l.low != nil { + // buildLowLink never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRef(l.low, buildLowLink, NewLink) + if rendered != nil { + return rendered, nil + } + } + return high.RenderInline(l, l.low) } @@ -105,6 +127,16 @@ func (l *Link) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { if l.Reference != "" { return utils.CreateRefNode(l.Reference), nil } + + // resolve external reference if present + if l.low != nil { + // buildLowLink never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRefWithContext(l.low, buildLowLink, NewLink, ctx) + if rendered != nil { + return rendered, nil + } + } + return high.RenderInlineWithContext(l, l.low, ctx) } diff --git a/datamodel/high/v3/link_test.go b/datamodel/high/v3/link_test.go index 847929da..13c464e0 100644 --- a/datamodel/high/v3/link_test.go +++ b/datamodel/high/v3/link_test.go @@ -4,10 +4,14 @@ package v3 import ( + "context" "strings" "testing" "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/datamodel/low" + v3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/stretchr/testify/assert" "go.yaml.in/yaml/v4" @@ -153,3 +157,108 @@ func TestLink_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowLink_Success(t *testing.T) { + yml := `operationId: getUser +description: A test link` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowLink(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "getUser", result.OperationId.Value) +} + +func TestBuildLowLink_BuildError(t *testing.T) { + // Links don't have schemas, so we need a different way to trigger Build error + // Links are quite simple and Build rarely fails, so we test the success path + yml := `operationId: test +description: test link` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowLink(node.Content[0], nil) + + // Links Build method is very resilient, so this should succeed + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestLink_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + links: + GetUserById: + $ref: "#/components/links/InternalLink" + InternalLink: + operationId: getUser + description: Get user by ID +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.Link + linkNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.links.GetUserById + _ = low.BuildModel(linkNode, &n) + _ = n.Build(context.Background(), nil, linkNode, idx) + + l := NewLink(&n) + + result, err := l.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestLink_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + links: + GetUserById: + $ref: "#/components/links/InternalLink" + InternalLink: + operationId: getUser + description: Get user by ID +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.Link + linkNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.links.GetUserById + _ = low.BuildModel(linkNode, &n) + _ = n.Build(context.Background(), nil, linkNode, idx) + + l := NewLink(&n) + + ctx := base.NewInlineRenderContext() + result, err := l.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/v3/parameter.go b/datamodel/high/v3/parameter.go index 25916d54..f7ac1f2a 100644 --- a/datamodel/high/v3/parameter.go +++ b/datamodel/high/v3/parameter.go @@ -4,14 +4,28 @@ package v3 import ( + "context" + "github.com/pb33f/libopenapi/datamodel/high" "github.com/pb33f/libopenapi/datamodel/high/base" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" low "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowParameter builds a low-level Parameter from a resolved YAML node. +func buildLowParameter(node *yaml.Node, idx *index.SpecIndex) (*low.Parameter, error) { + var param low.Parameter + lowmodel.BuildModel(node, ¶m) + if err := param.Build(context.Background(), nil, node, idx); err != nil { + return nil, err + } + return ¶m, nil +} + // Parameter represents a high-level OpenAPI 3+ Parameter object, that is backed by a low-level one. // // A unique parameter is defined by a combination of a name and location. @@ -124,9 +138,16 @@ func (p *Parameter) MarshalYAMLInline() (interface{}, error) { if p.Reference != "" { return utils.CreateRefNode(p.Reference), nil } - nb := high.NewNodeBuilder(p, p.low) - nb.Resolve = true - return nb.Render(), nil + + // resolve external reference if present + if p.low != nil { + rendered, err := high.RenderExternalRef(p.low, buildLowParameter, NewParameter) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInline(p, p.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Parameter object, @@ -137,10 +158,16 @@ func (p *Parameter) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { if p.Reference != "" { return utils.CreateRefNode(p.Reference), nil } - nb := high.NewNodeBuilder(p, p.low) - nb.Resolve = true - nb.RenderContext = ctx - return nb.Render(), nil + + // resolve external reference if present + if p.low != nil { + rendered, err := high.RenderExternalRefWithContext(p.low, buildLowParameter, NewParameter, ctx) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInlineWithContext(p, p.low, ctx) } // IsExploded will return true if the parameter is exploded, false otherwise. diff --git a/datamodel/high/v3/parameter_test.go b/datamodel/high/v3/parameter_test.go index 177864f3..8c19fe8e 100644 --- a/datamodel/high/v3/parameter_test.go +++ b/datamodel/high/v3/parameter_test.go @@ -370,3 +370,128 @@ func TestParameter_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowParameter_Success(t *testing.T) { + // Test the success path of buildLowParameter + yml := `name: testParam +in: query +description: A test parameter +required: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowParameter(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "testParam", result.Name.Value) + assert.Equal(t, "query", result.In.Value) +} + +func TestBuildLowParameter_BuildError(t *testing.T) { + // Create a parameter with a schema that has an unresolvable $ref + // This triggers an error in ExtractSchema during Build + yml := `name: test +in: query +schema: + $ref: '#/components/schemas/DoesNotExist'` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + // Create an empty index - the ref won't be found + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&node, config) + + result, err := buildLowParameter(node.Content[0], idx) + + // The schema extraction should fail because the ref doesn't exist + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestParameter_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + // This covers the "if rendered != nil" path in MarshalYAMLInline + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + parameters: + FilterParam: + $ref: "#/components/parameters/InternalParam" + InternalParam: + name: filter + in: query + description: Filter query parameter + schema: + type: string +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + // Build the low-level parameter that has an internal reference + // When we call MarshalYAMLInline, it should resolve it + var n v3.Parameter + paramNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.parameters.FilterParam + _ = low.BuildModel(paramNode, &n) + _ = n.Build(context.Background(), nil, paramNode, idx) + + p := NewParameter(&n) + + // Call MarshalYAMLInline which should resolve the reference + result, err := p.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestParameter_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + parameters: + FilterParam: + $ref: "#/components/parameters/InternalParam" + InternalParam: + name: filter + in: query + description: Filter query parameter + schema: + type: string +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.Parameter + paramNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.parameters.FilterParam + _ = low.BuildModel(paramNode, &n) + _ = n.Build(context.Background(), nil, paramNode, idx) + + p := NewParameter(&n) + + ctx := base.NewInlineRenderContext() + result, err := p.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/v3/path_item.go b/datamodel/high/v3/path_item.go index d39109cd..9c1c3253 100644 --- a/datamodel/high/v3/path_item.go +++ b/datamodel/high/v3/path_item.go @@ -4,17 +4,30 @@ package v3 import ( + "context" "reflect" "slices" "github.com/pb33f/libopenapi/datamodel/high" "github.com/pb33f/libopenapi/datamodel/low" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" lowV3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowPathItem builds a low-level PathItem from a resolved YAML node. +func buildLowPathItem(node *yaml.Node, idx *index.SpecIndex) (*lowV3.PathItem, error) { + var pi lowV3.PathItem + lowmodel.BuildModel(node, &pi) + if err := pi.Build(context.Background(), nil, node, idx); err != nil { + return nil, err + } + return &pi, nil +} + const ( get = iota put @@ -265,11 +278,16 @@ func (p *PathItem) MarshalYAMLInline() (interface{}, error) { if p.Reference != "" { return utils.CreateRefNode(p.Reference), nil } - nb := high.NewNodeBuilder(p, p.low) - nb.Resolve = true + // resolve external reference if present + if p.low != nil { + rendered, err := high.RenderExternalRef(p.low, buildLowPathItem, NewPathItem) + if err != nil || rendered != nil { + return rendered, err + } + } - return nb.Render(), nil + return high.RenderInline(p, p.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the PathItem object, @@ -280,10 +298,16 @@ func (p *PathItem) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { if p.Reference != "" { return utils.CreateRefNode(p.Reference), nil } - nb := high.NewNodeBuilder(p, p.low) - nb.Resolve = true - nb.RenderContext = ctx - return nb.Render(), nil + + // resolve external reference if present + if p.low != nil { + rendered, err := high.RenderExternalRefWithContext(p.low, buildLowPathItem, NewPathItem, ctx) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInlineWithContext(p, p.low, ctx) } // CreatePathItemRef creates a PathItem that renders as a $ref to another path item definition. diff --git a/datamodel/high/v3/path_item_test.go b/datamodel/high/v3/path_item_test.go index 5cd90a98..6d70c716 100644 --- a/datamodel/high/v3/path_item_test.go +++ b/datamodel/high/v3/path_item_test.go @@ -429,6 +429,40 @@ func TestPathItem_MarshalYAMLInline_Reference(t *testing.T) { assert.Equal(t, "$ref", yamlNode.Content[0].Value) } +func TestPathItem_MarshalYAMLInline_Reference_Bad(t *testing.T) { + // Test that a PathItem with a Reference set renders as a $ref node + // even when the low-level object also has a reference set + yml := `openapi: 3.2 +minty: fresh` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + + var lpi lowV3.PathItem + low.BuildModel(&yaml.Node{Value: "ref: cakes.yaml#/no"}, &lpi) + lpi.Build(context.Background(), &yaml.Node{Value: "ref: cakes.yaml#/no"}, idxNode.Content[0], idx) + if err := lpi.Build(context.Background(), &yaml.Node{Value: "ref: cakes.yaml#/no"}, &yaml.Node{Value: "ref: cakes.yaml#/no"}, idx); err != nil { + t.Fatal("failed to build") + return + } + ref := low.Reference{} + ref.SetReference("#/minty", nil) + lpi.Reference = &ref + pi := NewPathItem(&lpi) + + // Set the high-level Reference field directly since NewPathItem doesn't copy it + pi.Reference = "#/minty" + + node, err := pi.MarshalYAMLInline() + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} + func TestPathItem_Reference_TakesPrecedence(t *testing.T) { // When both Reference and content are set, Reference should take precedence pi := &PathItem{ @@ -522,3 +556,120 @@ func TestPathItem_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.Equal(t, "$ref", yamlNode.Content[0].Value) } +func TestBuildLowPathItem_Success(t *testing.T) { + yml := `summary: Test path item +get: + summary: Get operation` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowPathItem(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Test path item", result.Summary.Value) +} + +func TestBuildLowPathItem_BuildError(t *testing.T) { + // PathItem.Build can fail with invalid parameter refs + yml := `get: + parameters: + - $ref: '#/components/parameters/DoesNotExist'` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&node, config) + + result, err := buildLowPathItem(node.Content[0], idx) + + // PathItem Build can fail on unresolved refs in certain cases + if err != nil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + } +} + +func TestPathItem_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + pathItems: + CommonPath: + $ref: "#/components/pathItems/InternalPath" + InternalPath: + get: + summary: Common GET operation + responses: + "200": + description: OK +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n lowV3.PathItem + pathNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.pathItems.CommonPath + _ = low.BuildModel(pathNode, &n) + _ = n.Build(context.Background(), nil, pathNode, idx) + + pi := NewPathItem(&n) + + result, err := pi.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestPathItem_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + pathItems: + CommonPath: + $ref: "#/components/pathItems/InternalPath" + InternalPath: + get: + summary: Common GET operation + responses: + "200": + description: OK +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n lowV3.PathItem + pathNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.pathItems.CommonPath + _ = low.BuildModel(pathNode, &n) + _ = n.Build(context.Background(), nil, pathNode, idx) + + pi := NewPathItem(&n) + + ctx := base.NewInlineRenderContext() + result, err := pi.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/v3/request_body.go b/datamodel/high/v3/request_body.go index 535f3f04..0ce7bd9a 100644 --- a/datamodel/high/v3/request_body.go +++ b/datamodel/high/v3/request_body.go @@ -4,13 +4,25 @@ package v3 import ( + "context" + "github.com/pb33f/libopenapi/datamodel/high" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" low "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowRequestBody builds a low-level RequestBody from a resolved YAML node. +func buildLowRequestBody(node *yaml.Node, idx *index.SpecIndex) (*low.RequestBody, error) { + var rb low.RequestBody + lowmodel.BuildModel(node, &rb) + rb.Build(context.Background(), nil, node, idx) + return &rb, nil +} + // RequestBody represents a high-level OpenAPI 3+ RequestBody object, backed by a low-level one. // - https://spec.openapis.org/oas/v3.1.0#request-body-object type RequestBody struct { @@ -82,9 +94,17 @@ func (r *RequestBody) MarshalYAMLInline() (interface{}, error) { if r.Reference != "" { return utils.CreateRefNode(r.Reference), nil } - nb := high.NewNodeBuilder(r, r.low) - nb.Resolve = true - return nb.Render(), nil + + // resolve external reference if present + if r.low != nil { + // buildLowRequestBody never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRef(r.low, buildLowRequestBody, NewRequestBody) + if rendered != nil { + return rendered, nil + } + } + + return high.RenderInline(r, r.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the RequestBody object, @@ -95,10 +115,17 @@ func (r *RequestBody) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) if r.Reference != "" { return utils.CreateRefNode(r.Reference), nil } - nb := high.NewNodeBuilder(r, r.low) - nb.Resolve = true - nb.RenderContext = ctx - return nb.Render(), nil + + // resolve external reference if present + if r.low != nil { + // buildLowRequestBody never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRefWithContext(r.low, buildLowRequestBody, NewRequestBody, ctx) + if rendered != nil { + return rendered, nil + } + } + + return high.RenderInlineWithContext(r, r.low, ctx) } // CreateRequestBodyRef creates a RequestBody that renders as a $ref to another request body definition. diff --git a/datamodel/high/v3/request_body_test.go b/datamodel/high/v3/request_body_test.go index 4d11a624..81abd4bf 100644 --- a/datamodel/high/v3/request_body_test.go +++ b/datamodel/high/v3/request_body_test.go @@ -4,10 +4,14 @@ package v3 import ( + "context" "strings" "testing" "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/datamodel/low" + v3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" @@ -197,3 +201,123 @@ func TestRequestBody_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowRequestBody_Success(t *testing.T) { + yml := `description: A test request body +required: true +content: + application/json: + schema: + type: object` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowRequestBody(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "A test request body", result.Description.Value) +} + +func TestBuildLowRequestBody_BuildNeverErrors(t *testing.T) { + // RequestBody.Build never returns an error (no error return paths in the Build method) + // This test verifies the success path + yml := `description: test +content: + application/json: + schema: + type: string` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowRequestBody(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestRequestBody_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + requestBodies: + UserInput: + $ref: "#/components/requestBodies/InternalBody" + InternalBody: + description: User input data + required: true + content: + application/json: + schema: + type: object +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.RequestBody + bodyNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.requestBodies.UserInput + _ = low.BuildModel(bodyNode, &n) + _ = n.Build(context.Background(), nil, bodyNode, idx) + + rb := NewRequestBody(&n) + + result, err := rb.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestRequestBody_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + requestBodies: + UserInput: + $ref: "#/components/requestBodies/InternalBody" + InternalBody: + description: User input data + required: true + content: + application/json: + schema: + type: object +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.RequestBody + bodyNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.requestBodies.UserInput + _ = low.BuildModel(bodyNode, &n) + _ = n.Build(context.Background(), nil, bodyNode, idx) + + rb := NewRequestBody(&n) + + ctx := base.NewInlineRenderContext() + result, err := rb.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} + diff --git a/datamodel/high/v3/response.go b/datamodel/high/v3/response.go index f63002ab..418065be 100644 --- a/datamodel/high/v3/response.go +++ b/datamodel/high/v3/response.go @@ -4,14 +4,28 @@ package v3 import ( + "context" + "github.com/pb33f/libopenapi/datamodel/high" "github.com/pb33f/libopenapi/datamodel/low" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowResponse builds a low-level Response from a resolved YAML node. +func buildLowResponse(node *yaml.Node, idx *index.SpecIndex) (*lowv3.Response, error) { + var resp lowv3.Response + lowmodel.BuildModel(node, &resp) + if err := resp.Build(context.Background(), nil, node, idx); err != nil { + return nil, err + } + return &resp, nil +} + // Response represents a high-level OpenAPI 3+ Response object that is backed by a low-level one. // // Describes a single response from an API Operation, including design-time, static links to @@ -94,9 +108,16 @@ func (r *Response) MarshalYAMLInline() (interface{}, error) { if r.Reference != "" { return utils.CreateRefNode(r.Reference), nil } - nb := high.NewNodeBuilder(r, r.low) - nb.Resolve = true - return nb.Render(), nil + + // resolve external reference if present + if r.low != nil { + rendered, err := high.RenderExternalRef(r.low, buildLowResponse, NewResponse) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInline(r, r.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Response object, @@ -107,10 +128,16 @@ func (r *Response) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { if r.Reference != "" { return utils.CreateRefNode(r.Reference), nil } - nb := high.NewNodeBuilder(r, r.low) - nb.Resolve = true - nb.RenderContext = ctx - return nb.Render(), nil + + // resolve external reference if present + if r.low != nil { + rendered, err := high.RenderExternalRefWithContext(r.low, buildLowResponse, NewResponse, ctx) + if err != nil || rendered != nil { + return rendered, err + } + } + + return high.RenderInlineWithContext(r, r.low, ctx) } // CreateResponseRef creates a Response that renders as a $ref to another response definition. diff --git a/datamodel/high/v3/response_test.go b/datamodel/high/v3/response_test.go index a222986e..711756f7 100644 --- a/datamodel/high/v3/response_test.go +++ b/datamodel/high/v3/response_test.go @@ -222,3 +222,120 @@ func TestResponse_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.True(t, ok) assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowResponse_Success(t *testing.T) { + yml := `description: A successful response +content: + application/json: + schema: + type: object` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowResponse(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "A successful response", result.Description.Value) +} + +func TestBuildLowResponse_BuildError(t *testing.T) { + yml := `description: test +content: + application/json: + schema: + $ref: '#/components/schemas/DoesNotExist'` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&node, config) + + result, err := buildLowResponse(node.Content[0], idx) + + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestResponse_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + responses: + NotFound: + $ref: "#/components/responses/InternalResponse" + InternalResponse: + description: Resource not found + content: + application/json: + schema: + type: object +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.Response + respNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.responses.NotFound + _ = low.BuildModel(respNode, &n) + _ = n.Build(context.Background(), nil, respNode, idx) + + r := NewResponse(&n) + + result, err := r.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestResponse_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + responses: + NotFound: + $ref: "#/components/responses/InternalResponse" + InternalResponse: + description: Resource not found + content: + application/json: + schema: + type: object +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.Response + respNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.responses.NotFound + _ = low.BuildModel(respNode, &n) + _ = n.Build(context.Background(), nil, respNode, idx) + + r := NewResponse(&n) + + ctx := base.NewInlineRenderContext() + result, err := r.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/v3/security_scheme.go b/datamodel/high/v3/security_scheme.go index 3573a6d9..512117f7 100644 --- a/datamodel/high/v3/security_scheme.go +++ b/datamodel/high/v3/security_scheme.go @@ -4,13 +4,25 @@ package v3 import ( + "context" + "github.com/pb33f/libopenapi/datamodel/high" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" low "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" ) +// buildLowSecurityScheme builds a low-level SecurityScheme from a resolved YAML node. +func buildLowSecurityScheme(node *yaml.Node, idx *index.SpecIndex) (*low.SecurityScheme, error) { + var ss low.SecurityScheme + lowmodel.BuildModel(node, &ss) + ss.Build(context.Background(), nil, node, idx) + return &ss, nil +} + // SecurityScheme represents a high-level OpenAPI 3+ SecurityScheme object that is backed by a low-level one. // // Defines a security scheme that can be used by the operations. @@ -99,6 +111,16 @@ func (s *SecurityScheme) MarshalYAMLInline() (interface{}, error) { if s.Reference != "" { return utils.CreateRefNode(s.Reference), nil } + + // resolve external reference if present + if s.low != nil { + // buildLowSecurityScheme never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRef(s.low, buildLowSecurityScheme, NewSecurityScheme) + if rendered != nil { + return rendered, nil + } + } + return high.RenderInline(s, s.low) } @@ -110,6 +132,16 @@ func (s *SecurityScheme) MarshalYAMLInlineWithContext(ctx any) (interface{}, err if s.Reference != "" { return utils.CreateRefNode(s.Reference), nil } + + // resolve external reference if present + if s.low != nil { + // buildLowSecurityScheme never returns an error, so we can ignore it + rendered, _ := high.RenderExternalRefWithContext(s.low, buildLowSecurityScheme, NewSecurityScheme, ctx) + if rendered != nil { + return rendered, nil + } + } + return high.RenderInlineWithContext(s, s.low, ctx) } diff --git a/datamodel/high/v3/security_scheme_test.go b/datamodel/high/v3/security_scheme_test.go index c2d37c15..1ffbc27f 100644 --- a/datamodel/high/v3/security_scheme_test.go +++ b/datamodel/high/v3/security_scheme_test.go @@ -157,3 +157,111 @@ func TestSecurityScheme_MarshalYAMLInlineWithContext_Reference(t *testing.T) { assert.Equal(t, "$ref", yamlNode.Content[0].Value) } + +func TestBuildLowSecurityScheme_Success(t *testing.T) { + yml := `type: apiKey +name: X-API-Key +in: header` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowSecurityScheme(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "apiKey", result.Type.Value) +} + +func TestBuildLowSecurityScheme_BuildNeverErrors(t *testing.T) { + // SecurityScheme.Build never returns an error (no error return paths in the Build method) + // This test verifies the success path + yml := `type: http +scheme: bearer +bearerFormat: JWT` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + assert.NoError(t, err) + + result, err := buildLowSecurityScheme(node.Content[0], nil) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestSecurityScheme_MarshalYAMLInline_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInline resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + securitySchemes: + BearerAuth: + $ref: "#/components/securitySchemes/InternalAuth" + InternalAuth: + type: http + scheme: bearer + bearerFormat: JWT +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.SecurityScheme + schemeNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.securitySchemes.BearerAuth + _ = low.BuildModel(schemeNode, &n) + _ = n.Build(context.Background(), nil, schemeNode, idx) + + ss := NewSecurityScheme(&n) + + result, err := ss.MarshalYAMLInline() + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestSecurityScheme_MarshalYAMLInlineWithContext_ExternalRef(t *testing.T) { + // Test that MarshalYAMLInlineWithContext resolves external references properly + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +components: + securitySchemes: + BearerAuth: + $ref: "#/components/securitySchemes/InternalAuth" + InternalAuth: + type: http + scheme: bearer + bearerFormat: JWT +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + config := index.CreateOpenAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&idxNode, config) + resolver := index.NewResolver(idx) + idx.SetResolver(resolver) + errs := resolver.Resolve() + assert.Empty(t, errs) + + var n v3.SecurityScheme + schemeNode := idxNode.Content[0].Content[5].Content[1].Content[1] // components.securitySchemes.BearerAuth + _ = low.BuildModel(schemeNode, &n) + _ = n.Build(context.Background(), nil, schemeNode, idx) + + ss := NewSecurityScheme(&n) + + ctx := base.NewInlineRenderContext() + result, err := ss.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/low/extraction_functions.go b/datamodel/low/extraction_functions.go index c4ab0dd8..6e31af91 100644 --- a/datamodel/low/extraction_functions.go +++ b/datamodel/low/extraction_functions.go @@ -212,7 +212,7 @@ func LocateRefNodeWithContext(ctx context.Context, root *yaml.Node, idx *index.S // // check for a config BaseURL and use that if it exists. - if idx.GetConfig().BaseURL != nil { + if idx.GetConfig() != nil && idx.GetConfig().BaseURL != nil { u := *idx.GetConfig().BaseURL p := "" if u.Path != "" { diff --git a/datamodel/low/v3/components.go b/datamodel/low/v3/components.go index f491633b..3772902a 100644 --- a/datamodel/low/v3/components.go +++ b/datamodel/low/v3/components.go @@ -299,10 +299,6 @@ func extractComponentValues[T low.Buildable[N], N any](ctx context.Context, labe currentLabel = node continue } - // only check for lowercase extensions as 'X-' is still valid as a key (annoyingly). - if strings.HasPrefix(currentLabel.Value, "x-") { - continue - } select { case in <- componentInput{ diff --git a/datamodel/low/v3/components_test.go b/datamodel/low/v3/components_test.go index 0233b3fc..55c9c824 100644 --- a/datamodel/low/v3/components_test.go +++ b/datamodel/low/v3/components_test.go @@ -427,3 +427,308 @@ func TestComponents_MediaTypes(t *testing.T) { hash2 := n.Hash() assert.NotEqual(t, hash1, hash2) } + +// TestComponents_XPrefixedComponentNames tests that component names starting with x- are correctly +// parsed as components and not incorrectly filtered out as extensions. +// This is a regression test for https://github.com/pb33f/libopenapi/issues/503 +func TestComponents_XPrefixedComponentNames(t *testing.T) { + low.ClearHashCache() + + yml := `schemas: + x-custom-schema: + type: object + description: A schema with x- prefix + RegularSchema: + type: string +parameters: + x-custom-param: + name: x-custom-param + in: header + schema: + type: string + regular-param: + name: regular-param + in: query + schema: + type: string +responses: + x-custom-response: + description: A response with x- prefix +headers: + x-rate-limit: + schema: + type: integer + description: Rate limit header +examples: + x-custom-example: + value: example-value + description: An example with x- prefix +requestBodies: + x-custom-body: + description: A request body with x- prefix + content: + application/json: + schema: + type: object +securitySchemes: + x-custom-auth: + type: apiKey + name: X-API-Key + in: header + description: Custom auth scheme +links: + x-custom-link: + description: A link with x- prefix +callbacks: + x-custom-callback: + '{$request.body#/callbackUrl}': + post: + description: Callback operation +pathItems: + /x-custom-path: + get: + description: A path item with x- prefix +mediaTypes: + x-custom-media: + schema: + type: object + description: Custom media type` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + idx := index.NewSpecIndex(&idxNode) + + var n Components + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], idx) + assert.NoError(t, err) + + // Test x-prefixed schemas are included + xSchema := n.FindSchema("x-custom-schema") + assert.NotNil(t, xSchema, "x-custom-schema should be found") + assert.Equal(t, "A schema with x- prefix", xSchema.Value.Schema().Description.Value) + + regularSchema := n.FindSchema("RegularSchema") + assert.NotNil(t, regularSchema, "RegularSchema should also be found") + + // Test x-prefixed parameters are included + xParam := n.FindParameter("x-custom-param") + assert.NotNil(t, xParam, "x-custom-param should be found") + assert.Equal(t, "x-custom-param", xParam.Value.Name.Value) + assert.Equal(t, "header", xParam.Value.In.Value) + + regularParam := n.FindParameter("regular-param") + assert.NotNil(t, regularParam, "regular-param should also be found") + + // Test x-prefixed responses are included + xResponse := n.FindResponse("x-custom-response") + assert.NotNil(t, xResponse, "x-custom-response should be found") + assert.Equal(t, "A response with x- prefix", xResponse.Value.Description.Value) + + // Test x-prefixed headers are included + xHeader := n.FindHeader("x-rate-limit") + assert.NotNil(t, xHeader, "x-rate-limit should be found") + assert.Equal(t, "Rate limit header", xHeader.Value.Description.Value) + + // Test x-prefixed examples are included + xExample := n.FindExample("x-custom-example") + assert.NotNil(t, xExample, "x-custom-example should be found") + assert.Equal(t, "An example with x- prefix", xExample.Value.Description.Value) + + // Test x-prefixed request bodies are included + xRequestBody := n.FindRequestBody("x-custom-body") + assert.NotNil(t, xRequestBody, "x-custom-body should be found") + assert.Equal(t, "A request body with x- prefix", xRequestBody.Value.Description.Value) + + // Test x-prefixed security schemes are included + xSecurityScheme := n.FindSecurityScheme("x-custom-auth") + assert.NotNil(t, xSecurityScheme, "x-custom-auth should be found") + assert.Equal(t, "Custom auth scheme", xSecurityScheme.Value.Description.Value) + assert.Equal(t, "apiKey", xSecurityScheme.Value.Type.Value) + + // Test x-prefixed links are included + xLink := n.FindLink("x-custom-link") + assert.NotNil(t, xLink, "x-custom-link should be found") + assert.Equal(t, "A link with x- prefix", xLink.Value.Description.Value) + + // Test x-prefixed callbacks are included + xCallback := n.FindCallback("x-custom-callback") + assert.NotNil(t, xCallback, "x-custom-callback should be found") + expr := xCallback.Value.FindExpression("{$request.body#/callbackUrl}") + assert.NotNil(t, expr, "Callback expression should be found") + assert.Equal(t, "Callback operation", expr.Value.Post.Value.Description.Value) + + // Test x-prefixed path items are included + xPathItem := n.FindPathItem("/x-custom-path") + assert.NotNil(t, xPathItem, "/x-custom-path should be found") + assert.Equal(t, "A path item with x- prefix", xPathItem.Value.Get.Value.Description.Value) + + // Test x-prefixed media types are included + xMediaType := n.FindMediaType("x-custom-media") + assert.NotNil(t, xMediaType, "x-custom-media should be found") + assert.Equal(t, "Custom media type", xMediaType.Value.Schema.Value.Schema().Description.Value) +} + +// TestComponents_XPrefixedWithUpperCase tests that both x- (lowercase) and X- (uppercase) +// prefixed component names are correctly parsed. +func TestComponents_XPrefixedWithUpperCase(t *testing.T) { + low.ClearHashCache() + + yml := `schemas: + x-lowercase-schema: + type: string + description: lowercase x- prefix + X-UPPERCASE-SCHEMA: + type: string + description: uppercase X- prefix +parameters: + x-lowercase-param: + name: x-param + in: header + schema: + type: string + X-UPPERCASE-PARAM: + name: X-param + in: header + schema: + type: string` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + idx := index.NewSpecIndex(&idxNode) + + var n Components + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], idx) + assert.NoError(t, err) + + // Test lowercase x- prefix + xLowerSchema := n.FindSchema("x-lowercase-schema") + assert.NotNil(t, xLowerSchema, "x-lowercase-schema should be found") + assert.Equal(t, "lowercase x- prefix", xLowerSchema.Value.Schema().Description.Value) + + // Test uppercase X- prefix + xUpperSchema := n.FindSchema("X-UPPERCASE-SCHEMA") + assert.NotNil(t, xUpperSchema, "X-UPPERCASE-SCHEMA should be found") + assert.Equal(t, "uppercase X- prefix", xUpperSchema.Value.Schema().Description.Value) + + // Test lowercase x- param + xLowerParam := n.FindParameter("x-lowercase-param") + assert.NotNil(t, xLowerParam, "x-lowercase-param should be found") + + // Test uppercase X- param + xUpperParam := n.FindParameter("X-UPPERCASE-PARAM") + assert.NotNil(t, xUpperParam, "X-UPPERCASE-PARAM should be found") +} + +// TestComponents_XPrefixedExtensionsStillWork verifies that extensions at the Components level +// (like x-custom-extension) are still captured correctly, while x-prefixed component names +// within schemas/parameters/etc are also captured. +func TestComponents_XPrefixedExtensionsStillWork(t *testing.T) { + low.ClearHashCache() + + yml := `x-components-extension: this is an extension at components level +x-another-extension: + nested: value +schemas: + x-custom-schema: + type: object + description: This is a schema, not an extension + RegularSchema: + type: string` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + idx := index.NewSpecIndex(&idxNode) + + var n Components + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], idx) + assert.NoError(t, err) + + // Extensions at Components level should still work + ext1 := n.FindExtension("x-components-extension") + assert.NotNil(t, ext1, "x-components-extension should be found as extension") + var ext1Val string + _ = ext1.Value.Decode(&ext1Val) + assert.Equal(t, "this is an extension at components level", ext1Val) + + ext2 := n.FindExtension("x-another-extension") + assert.NotNil(t, ext2, "x-another-extension should be found as extension") + + // x-prefixed schemas should be found as schemas + xSchema := n.FindSchema("x-custom-schema") + assert.NotNil(t, xSchema, "x-custom-schema should be found as a schema") + assert.Equal(t, "This is a schema, not an extension", xSchema.Value.Schema().Description.Value) + + // Regular schemas also work + regularSchema := n.FindSchema("RegularSchema") + assert.NotNil(t, regularSchema, "RegularSchema should be found") +} + +// TestComponents_XPrefixedReferenceResolution tests that references to x-prefixed components +// resolve correctly. +func TestComponents_XPrefixedReferenceResolution(t *testing.T) { + low.ClearHashCache() + + yml := `schemas: + x-base-schema: + type: object + properties: + id: + type: integer + derived-schema: + allOf: + - $ref: '#/schemas/x-base-schema' + - type: object + properties: + name: + type: string +parameters: + x-auth-header: + name: Authorization + in: header + schema: + type: string + uses-x-param: + $ref: '#/parameters/x-auth-header'` + + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &idxNode) + assert.NoError(t, mErr) + idx := index.NewSpecIndex(&idxNode) + + var n Components + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], idx) + assert.NoError(t, err) + + // The x-prefixed schema should be found + xBaseSchema := n.FindSchema("x-base-schema") + assert.NotNil(t, xBaseSchema, "x-base-schema should be found") + + // The derived schema should also be found + derivedSchema := n.FindSchema("derived-schema") + assert.NotNil(t, derivedSchema, "derived-schema should be found") + + // The x-prefixed parameter should be found + xAuthHeader := n.FindParameter("x-auth-header") + assert.NotNil(t, xAuthHeader, "x-auth-header should be found") + assert.Equal(t, "Authorization", xAuthHeader.Value.Name.Value) + + // The parameter that references x-auth-header should have reference + usesXParam := n.FindParameter("uses-x-param") + assert.NotNil(t, usesXParam, "uses-x-param should be found") + assert.Equal(t, "#/parameters/x-auth-header", usesXParam.Value.GetReference()) +} diff --git a/datamodel/low/v3/paths.go b/datamodel/low/v3/paths.go index 5da63b11..eeaaf43e 100644 --- a/datamodel/low/v3/paths.go +++ b/datamodel/low/v3/paths.go @@ -159,27 +159,29 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn }() skip := false var currentNode *yaml.Node - for i, pathNode := range root.Content { - if strings.HasPrefix(strings.ToLower(pathNode.Value), "x-") { - skip = true - continue - } - if skip { - skip = false - continue - } - if i%2 == 0 { - currentNode = pathNode - continue - } + if root != nil { + for i, pathNode := range root.Content { + if strings.HasPrefix(strings.ToLower(pathNode.Value), "x-") { + skip = true + continue + } + if skip { + skip = false + continue + } + if i%2 == 0 { + currentNode = pathNode + continue + } - select { - case in <- buildInput{ - currentNode: currentNode, - pathNode: pathNode, - }: - case <-done: - return + select { + case in <- buildInput{ + currentNode: currentNode, + pathNode: pathNode, + }: + case <-done: + return + } } } }() diff --git a/go.mod b/go.mod index 6ba8cd91..8cf93042 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ module github.com/pb33f/libopenapi -go 1.25 +go 1.24.0 require ( github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb - github.com/pb33f/jsonpath v0.7.0 + github.com/pb33f/jsonpath v0.7.1 github.com/pb33f/ordered-map/v2 v2.3.0 github.com/stretchr/testify v1.11.1 - go.yaml.in/yaml/v4 v4.0.0-rc.3 + go.yaml.in/yaml/v4 v4.0.0-rc.4 + golang.org/x/sync v0.19.0 ) require ( @@ -17,6 +18,5 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect - golang.org/x/sync v0.19.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b7e9e111..8c7f3dec 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb h1:w1g9wNDIE/pHSTmAaUhv4TZQuPBS6GV3mMz5hkgziIU= github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb/go.mod h1:5ELEyG+X8f+meRWHuqUOewBOhvHkl7M76pdGEansxW4= -github.com/pb33f/jsonpath v0.7.0 h1:3oG6yu1RqNoMZpqnRjBMqi8fSIXWoDAKDrsB0QGTcoU= -github.com/pb33f/jsonpath v0.7.0/go.mod h1:/+JlSIjWA2ijMVYGJ3IQPF4Q1nLMYbUTYNdk0exCDPQ= +github.com/pb33f/jsonpath v0.7.1 h1:dEp6oIZuJbpDSyuHAl9m7GonoDW4M20BcD5vT0tPYRE= +github.com/pb33f/jsonpath v0.7.1/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ= github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -21,8 +21,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= -go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= +go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/index/search_index.go b/index/search_index.go index ca2db2f0..d3ad0e3a 100644 --- a/index/search_index.go +++ b/index/search_index.go @@ -14,9 +14,9 @@ import ( type ContextKey string const ( - CurrentPathKey ContextKey = "currentPath" - FoundIndexKey ContextKey = "foundIndex" - RootIndexKey ContextKey = "currentIndex" + CurrentPathKey ContextKey = "currentPath" + FoundIndexKey ContextKey = "foundIndex" + RootIndexKey ContextKey = "currentIndex" IndexingFilesKey ContextKey = "indexingFiles" // Tracks files being indexed in current call chain ) @@ -117,7 +117,7 @@ func (index *SpecIndex) SearchIndexForReferenceByReferenceWithContext(ctx contex if searchRef.RemoteLocation != "" { absPath = searchRef.RemoteLocation } - if absPath == "" { + if absPath == "" && index.config != nil { absPath = index.config.BasePath } var roloLookup string diff --git a/index/utility_methods.go b/index/utility_methods.go index 399de151..e725368e 100644 --- a/index/utility_methods.go +++ b/index/utility_methods.go @@ -5,9 +5,8 @@ package index import ( "context" - "crypto/sha256" "fmt" - "hash" + "hash/maphash" "net/url" "os" "path/filepath" @@ -646,180 +645,159 @@ func syncMapToMap[K comparable, V any](sm *sync.Map) map[K]V { // ClearHashCache clears the hash cache - useful for testing and memory management func ClearHashCache() { - hashCache.Range(func(key, value interface{}) bool { - hashCache.Delete(key) + nodeHashCache.Range(func(key, value interface{}) bool { + nodeHashCache.Delete(key) return true }) } -// Buffer pool for integer conversion in hashNode to avoid allocations -var bufferPool = sync.Pool{ +// hasherPool pools maphash.Hash instances to avoid allocations. +// maphash is ~15x faster than SHA256 and has native WriteString support. +var hasherPool = sync.Pool{ New: func() interface{} { - buf := make([]byte, 0, 64) - return &buf + h := &maphash.Hash{} + h.SetSeed(globalHashSeed) // ensure consistent hashes across pooled instances + return h }, } -// Hash cache for identical subtrees to avoid recomputation -var hashCache = sync.Map{} // string -> string (nodeID -> hash) - -// Performance thresholds for hybrid optimization -const ( - // Use optimized version for very large nodes (>1000 content items) - largeLodeThreshold = 1000 - // Use optimized version for very deep nodes (>100 levels) - deepNodeThreshold = 100 - // Cache node hashes when they have significant content - cacheThreshold = 200 -) - -// HashNode returns a consistent SHA256 hash string of the node and its children. -// it runs as fast as possible, but it's recursive, with a hard limit of 1000 levels deep. -// Uses a hybrid approach: simple hashing for small nodes, optimized for large/deep nodes. -func HashNode(n *yaml.Node) string { - if n == nil { - // Return hash of empty bytes for nil nodes (maintains compatibility) - h := sha256.New() - sum := h.Sum(nil) - return fmt.Sprintf("%x", sum) - } - - // Create a unique node identifier for caching - nodeID := fmt.Sprintf("%p_%s_%d_%d", n, n.Tag, n.Line, n.Column) - - // Check cache first for nodes with significant content - contentSize := len(n.Content) - if contentSize >= cacheThreshold { - if cached, ok := hashCache.Load(nodeID); ok { - return cached.(string) - } - } +// stackPool pools node pointer slices for HashNode traversal. +// avoids allocating ~1KB per HashNode call. +var stackPool = sync.Pool{ + New: func() interface{} { + s := make([]*yaml.Node, 0, 128) + return &s + }, +} - h := sha256.New() +// visitedPool pools visited maps for circular reference detection. +// avoids allocating ~2KB per HashNode call. +var visitedPool = sync.Pool{ + New: func() interface{} { + return make(map[*yaml.Node]struct{}, 64) + }, +} - // Determine if we should use optimized or simple hashing - useOptimized := shouldUseOptimizedHashing(n, 0) +// nodeHashCache caches hash results by node pointer for repeated lookups. +// yaml.Node pointers are stable for the document lifetime. +var nodeHashCache = sync.Map{} // *yaml.Node -> string - if useOptimized { - hashNodeOptimized(n, h, 0) - } else { - hashNodeSimple(n, h, 0) - } +// hashCacheThreshold determines when to cache hash results. +// lowered from 200 to 20 for more aggressive caching of repeated patterns. +const hashCacheThreshold = 20 - sum := h.Sum(nil) - result := fmt.Sprintf("%x", sum) +// globalHashSeed ensures all maphash instances produce consistent results. +// maphash uses random seeds by default; we need deterministic hashes for caching. +var globalHashSeed maphash.Seed - // Cache the result for large nodes - if contentSize >= cacheThreshold { - hashCache.Store(nodeID, result) - } +// emptyNodeHash is the hash of a nil node (computed once at init). +var emptyNodeHash string - return result +func init() { + globalHashSeed = maphash.MakeSeed() + var h maphash.Hash + h.SetSeed(globalHashSeed) + emptyNodeHash = strconv.FormatUint(h.Sum64(), 16) } -// shouldUseOptimizedHashing determines if we should use the optimized (slower but memory-efficient) -// version of hashing based on node characteristics -func shouldUseOptimizedHashing(n *yaml.Node, depth int) bool { - if n == nil { - return false - } - - // Use optimized version for large nodes - if len(n.Content) > largeLodeThreshold { - return true +// writeIntToHash writes an integer to the hash without heap allocations. +// uses a stack-allocated buffer to avoid strconv.Itoa's string allocation. +func writeIntToHash(h *maphash.Hash, n int) { + if n == 0 { + h.WriteByte('0') + return } - - // Use optimized version for deep nodes - if depth > deepNodeThreshold { - return true + if n < 0 { + h.WriteByte('-') + n = -n } - - // Check if any immediate children are large - for _, child := range n.Content { - if len(child.Content) > largeLodeThreshold { - return true - } + // max int64 is 19 digits, 20 is safe + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 } - - return false + h.Write(buf[i:]) } -// hashNodeOptimized is the memory-optimized version using buffer pools -func hashNodeOptimized(n *yaml.Node, h hash.Hash, depth int) { +// HashNode returns a fast hash string of the node and its children. +// Uses maphash (same algorithm as Go maps) with WriteString for zero allocations. +// Iterative traversal avoids recursion overhead. +func HashNode(n *yaml.Node) string { if n == nil { - return + return emptyNodeHash } - if depth > 1000 { - // Prevent extremely deep recursion from using too much stack. - return - } - - // Get buffer from pool - bufPtr := bufferPool.Get().(*[]byte) - buf := (*bufPtr)[:0] - defer bufferPool.Put(bufPtr) - - // Write Tag - h.Write([]byte(n.Tag)) - // Write Line - buf = strconv.AppendInt(buf, int64(n.Line), 10) - h.Write(buf) - - // Reuse buffer for Column - buf = buf[:0] - buf = strconv.AppendInt(buf, int64(n.Column), 10) - h.Write(buf) + // check cache first (by pointer - yaml.Node pointers are stable) + if cached, ok := nodeHashCache.Load(n); ok { + return cached.(string) + } - // Write Value - h.Write([]byte(n.Value)) + // get hasher from pool + h := hasherPool.Get().(*maphash.Hash) + h.Reset() + defer hasherPool.Put(h) - // Recurse over Content with optimized path selection - for _, c := range n.Content { - if shouldUseOptimizedHashing(c, depth+1) { - hashNodeOptimized(c, h, depth+1) + // get stack from pool, reset length but keep capacity + stackPtr := stackPool.Get().(*[]*yaml.Node) + origStack := *stackPtr // save original before any growth + stack := origStack[:0] + stack = append(stack, n) + defer func() { + if cap(stack) > 256 { + // stack grew too large - return original small slice to pool + // let the grown slice be GC'd + *stackPtr = origStack[:0] } else { - hashNodeSimple(c, h, depth+1) + // acceptable size - return current (possibly grown) slice + *stackPtr = stack[:0] + } + stackPool.Put(stackPtr) + }() + + // get visited map from pool, clear entries + visited := visitedPool.Get().(map[*yaml.Node]struct{}) + clear(visited) + defer func() { + clear(visited) + visitedPool.Put(visited) + }() + + for len(stack) > 0 { + // pop from stack + node := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + if node == nil { + continue } - } -} -// hashNodeSimple is the fast version for small nodes (uses minimal buffer pool) -func hashNodeSimple(n *yaml.Node, h hash.Hash, depth int) { - if n == nil { - return - } - if depth > 1000 { - // Prevent extremely deep recursion from using too much stack. - return - } - - // Get buffer from pool even for simple case to avoid allocations - bufPtr := bufferPool.Get().(*[]byte) - buf := (*bufPtr)[:0] - defer bufferPool.Put(bufPtr) - - // Write Tag directly - h.Write([]byte(n.Tag)) + // skip already visited nodes (handles circular references) + if _, seen := visited[node]; seen { + continue + } + visited[node] = struct{}{} - // Write Line using buffer (no allocations) - buf = strconv.AppendInt(buf, int64(n.Line), 10) - h.Write(buf) + // hash node content - WriteString for strings, writeIntToHash for ints (zero allocations) + h.WriteString(node.Tag) + writeIntToHash(h, node.Line) + writeIntToHash(h, node.Column) + h.WriteString(node.Value) - // Reuse buffer for Column - buf = buf[:0] - buf = strconv.AppendInt(buf, int64(n.Column), 10) - h.Write(buf) + // push children in reverse order for correct traversal order + for i := len(node.Content) - 1; i >= 0; i-- { + stack = append(stack, node.Content[i]) + } + } - // Write Value directly - h.Write([]byte(n.Value)) + result := strconv.FormatUint(h.Sum64(), 16) - // Recurse over Content with path selection - for _, c := range n.Content { - if shouldUseOptimizedHashing(c, depth+1) { - hashNodeOptimized(c, h, depth+1) - } else { - hashNodeSimple(c, h, depth+1) - } + // cache result for nodes with children (likely to be looked up again) + if len(n.Content) >= hashCacheThreshold { + nodeHashCache.Store(n, result) } + + return result } diff --git a/index/utility_methods_buffer_test.go b/index/utility_methods_buffer_test.go index f8ac1357..ead3be1e 100644 --- a/index/utility_methods_buffer_test.go +++ b/index/utility_methods_buffer_test.go @@ -4,7 +4,6 @@ package index import ( - "crypto/sha256" "fmt" "strings" "testing" @@ -26,7 +25,7 @@ func TestHashNode_BufferPoolConsistency(t *testing.T) { chicken: wing beef: burger pork: chop`, - expected: "e9aba1ce94ac8bd0143524ce594c0c7d38c06c09eca7ae96725187f488661fcd", + expected: "", // consistency check only (hash algorithm may vary) }, { name: "nested structure", @@ -243,54 +242,6 @@ func TestClearHashCache_ComprehensiveTest(t *testing.T) { } } -// Test shouldUseOptimizedHashing with large node threshold -func TestShouldUseOptimizedHashing_LargeNode(t *testing.T) { - // Create a node with > 1000 content items (largeLodeThreshold) - largeNode := &yaml.Node{ - Kind: yaml.MappingNode, - Content: make([]*yaml.Node, 1001), - } - for i := 0; i < 1001; i++ { - largeNode.Content[i] = &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("item%d", i)} - } - - // Should use optimized hashing for large nodes - assert.True(t, shouldUseOptimizedHashing(largeNode, 0)) -} - -// Test shouldUseOptimizedHashing with deep node threshold -func TestShouldUseOptimizedHashing_DeepNode(t *testing.T) { - smallNode := &yaml.Node{Kind: yaml.ScalarNode, Value: "test"} - - // Should use optimized hashing for deep nodes (depth > 100) - assert.True(t, shouldUseOptimizedHashing(smallNode, 101)) - - // Should not use optimized for shallow nodes - assert.False(t, shouldUseOptimizedHashing(smallNode, 50)) -} - -// Test shouldUseOptimizedHashing with large children -func TestShouldUseOptimizedHashing_LargeChildren(t *testing.T) { - // Create parent with small content but large child - largeChild := &yaml.Node{ - Kind: yaml.MappingNode, - Content: make([]*yaml.Node, 1001), // Above threshold - } - - parentNode := &yaml.Node{ - Kind: yaml.MappingNode, - Content: []*yaml.Node{largeChild}, // Only one child, but it's large - } - - // Should use optimized hashing because child is large - assert.True(t, shouldUseOptimizedHashing(parentNode, 0)) -} - -// Test shouldUseOptimizedHashing with nil node -func TestShouldUseOptimizedHashing_NilNode(t *testing.T) { - assert.False(t, shouldUseOptimizedHashing(nil, 0)) -} - // Test HashNode with large node that triggers caching func TestHashNode_LargeNodeCaching(t *testing.T) { // Create a node with >= 200 content items to trigger caching @@ -359,32 +310,6 @@ func TestHashNode_VeryDeepRecursion(t *testing.T) { assert.NotEmpty(t, hash) } -// Test optimized vs simple hashing produce same results for same input -func TestHashNode_OptimizedVsSimple(t *testing.T) { - yamlStr := `test: - item1: value1 - item2: value2 - nested: - deep1: val1 - deep2: val2` - - var rootNode yaml.Node - err := yaml.Unmarshal([]byte(yamlStr), &rootNode) - assert.NoError(t, err) - - // Clear cache first - ClearHashCache() - - // Force different code paths by manipulating thresholds temporarily - // This tests that both paths produce identical results - hash1 := HashNode(&rootNode) - assert.NotEmpty(t, hash1) - - // Hash again should be identical regardless of path taken - hash2 := HashNode(&rootNode) - assert.Equal(t, hash1, hash2) -} - // Test hash functions with empty and edge case nodes func TestHashNode_EdgeCases(t *testing.T) { testCases := []struct { @@ -426,22 +351,21 @@ func TestHashNode_EdgeCases(t *testing.T) { } } -// Test specific branches in hashNodeOptimized and hashNodeSimple -func TestHashNode_ForceBranches(t *testing.T) { - // Create a node that will trigger optimized hashing (large content) +// Test hashing with large complex tree structures +func TestHashNode_LargeComplexTree(t *testing.T) { + // create a node with large content to test iterative traversal largeNode := &yaml.Node{ Kind: yaml.MappingNode, Tag: "!!map", Value: "root", Line: 1, Column: 1, - Content: make([]*yaml.Node, 1100), // Above largeLodeThreshold + Content: make([]*yaml.Node, 1100), } - // Fill with alternating small and large nodes to test both paths + // fill with mix of small and large nodes for i := 0; i < 1100; i++ { if i%2 == 0 { - // Small node - will use simple hashing largeNode.Content[i] = &yaml.Node{ Kind: yaml.ScalarNode, Tag: "!!str", @@ -450,16 +374,15 @@ func TestHashNode_ForceBranches(t *testing.T) { Column: 1, } } else { - // Large node - will use optimized hashing child := &yaml.Node{ Kind: yaml.MappingNode, Tag: "!!map", Value: fmt.Sprintf("large%d", i), Line: i + 2, Column: 1, - Content: make([]*yaml.Node, 1001), + Content: make([]*yaml.Node, 100), } - for j := 0; j < 1001; j++ { + for j := 0; j < 100; j++ { child.Content[j] = &yaml.Node{ Kind: yaml.ScalarNode, Value: fmt.Sprintf("item%d", j), @@ -471,16 +394,15 @@ func TestHashNode_ForceBranches(t *testing.T) { ClearHashCache() - // This should exercise both optimized and simple code paths hash := HashNode(largeNode) assert.NotEmpty(t, hash) - // Should be consistent + // should be consistent hash2 := HashNode(largeNode) assert.Equal(t, hash, hash2) } -// Test hashNodeOptimized and hashNodeSimple with empty content arrays +// Test HashNode with empty content arrays func TestHashNode_EmptyContentArrays(t *testing.T) { // Test with empty content arrays and various node types testNodes := []*yaml.Node{ @@ -602,8 +524,7 @@ func TestHashNode_InternalNilHandling(t *testing.T) { } } - // This should exercise both hashNodeOptimized and hashNodeSimple - // with various edge cases including empty content and deep nesting + // this exercises the iterative hashing with various edge cases hash := HashNode(rootNode) assert.NotEmpty(t, hash) @@ -712,48 +633,7 @@ func TestHashNode_ForceNilPaths(t *testing.T) { assert.Equal(t, hash, hash2) } -// Test hashNodeSimple with nil node (covers nil check) -func TestHashNodeSimple_NilNode(t *testing.T) { - var h = sha256.New() - - // Call hashNodeSimple with nil node - should return early without error - hashNodeSimple(nil, h, 0) - - // Hash should remain unchanged (no data written) - sum := h.Sum(nil) - result := fmt.Sprintf("%x", sum) - - // Should be the hash of empty bytes - expected := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - assert.Equal(t, expected, result) -} - -// Test hashNodeSimple with depth > 1000 (covers depth check) -func TestHashNodeSimple_ExceedsDepthLimit(t *testing.T) { - var h = sha256.New() - - // Create a simple node - node := &yaml.Node{ - Kind: yaml.ScalarNode, - Tag: "!!str", - Value: "test", - Line: 1, - Column: 1, - } - - // Call hashNodeSimple with depth > 1000 - should return early - hashNodeSimple(node, h, 1001) - - // Hash should remain unchanged (no data written due to depth limit) - sum := h.Sum(nil) - result := fmt.Sprintf("%x", sum) - - // Should be the hash of empty bytes - expected := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - assert.Equal(t, expected, result) -} - -// Test to trigger edge cases in hashNodeOptimized and hashNodeSimple +// Test to trigger edge cases in hashing various node structures func TestHashNode_TriggerAllPaths(t *testing.T) { testCases := []struct { name string diff --git a/index/utility_methods_test.go b/index/utility_methods_test.go index 6228968d..0325586c 100644 --- a/index/utility_methods_test.go +++ b/index/utility_methods_test.go @@ -5,6 +5,7 @@ package index import ( "context" + "hash/maphash" "net/url" "runtime" "testing" @@ -284,24 +285,41 @@ pork: chop` hash := HashNode(&rootNode) assert.NotEmpty(t, hash) - assert.Equal(t, "e9aba1ce94ac8bd0143524ce594c0c7d38c06c09eca7ae96725187f488661fcd", hash) + + // verify consistency - hash should be same on repeated calls + hash2 := HashNode(&rootNode) + assert.Equal(t, hash, hash2) } func Test_HashNode_TooDeep(t *testing.T) { nodeA := &yaml.Node{} nodeB := &yaml.Node{} - // create an infinite loop. + // create an infinite loop (circular reference) nodeA.Content = append(nodeA.Content, nodeB) nodeB.Content = append(nodeB.Content, nodeA) + // should complete without infinite loop due to visited tracking hash := HashNode(nodeA) assert.NotEmpty(t, hash) - assert.Equal(t, "e6d506f4b5a87b3f37ac8bed41c8411a5883b5f20d141d45ee92245c023a73e4", hash) + + // verify consistency + hash2 := HashNode(nodeA) + assert.Equal(t, hash, hash2) } func Test_HashNode_Nil(t *testing.T) { var nodeA *yaml.Node hash := HashNode(nodeA) - assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash) + assert.NotEmpty(t, hash) // nil node should still produce a hash +} + +func Test_WriteIntToHash(t *testing.T) { + h := maphash.Hash{} + writeIntToHash(&h, -42) + assert.NotZero(t, h.Sum64()) +} + +func Test_Empty_HashNode(t *testing.T) { + assert.Equal(t, emptyNodeHash, HashNode(nil)) } diff --git a/test_specs/nested_files/components/responses/4xxClientErrors.yaml b/test_specs/nested_files/components/responses/4xxClientErrors.yaml new file mode 100644 index 00000000..3538e754 --- /dev/null +++ b/test_specs/nested_files/components/responses/4xxClientErrors.yaml @@ -0,0 +1,20 @@ +400_unexpected_request_body: + description: Unexpected request body provided. + content: + application/json: + schema: + additionalProperties: false + properties: + message: + type: string + default: Unexpected request body provided. +403_permission_denied: + description: None or insufficient credentials provided. + content: + application/json: + schema: + additionalProperties: false + properties: + message: + type: string + default: Permission denied. diff --git a/utils/utils.go b/utils/utils.go index 4492912a..91c5b4f1 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -698,14 +698,36 @@ var ( bracketNameExp = regexp.MustCompile(`^(\w+)\['?([\w/]+)'?]$`) ) -// isPathChar checks if a string contains only alphanumeric, underscore, or backslash characters -// This is an optimized replacement for the pathCharExp regex +// isPathChar checks if a string is valid for JSONPath dot notation. +// returns true only if the string contains only alphanumeric, underscore, or backslash characters +// and does not start with a digit (unless it's a pure integer, which is handled separately). +// jsonPath requires bracket notation for property names starting with digits like "403_permission_denied". +// this is an optimized replacement for the pathCharExp regex. func isPathChar(s string) bool { + if len(s) == 0 { + return false + } + + firstChar := s[0] + startsWithDigit := firstChar >= '0' && firstChar <= '9' + allDigits := startsWithDigit + + // single pass: validate characters and track if all are digits for _, r := range s { if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '\\') { return false } + if allDigits && (r < '0' || r > '9') { + allDigits = false + } } + + // if starts with digit but not pure integer, requires bracket notation + // property names like "403_permission_denied" must use bracket notation + if startsWithDigit && !allDigits { + return false + } + return true } diff --git a/utils/utils_test.go b/utils/utils_test.go index 0a14473c..31726e48 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -1663,6 +1663,72 @@ func TestConvertComponentIdIntoFriendlyPathSearch_ExtremeEdgeCases(t *testing.T) } } +// https://github.com/pb33f/libopenapi/issues/500 +// Test digit-starting property names require bracket notation in JSONPath +func TestConvertComponentIdIntoFriendlyPathSearch_DigitStartingSegments(t *testing.T) { + // Root-level key starting with digit (like error codes) + segment, path := ConvertComponentIdIntoFriendlyPathSearch("#/403_permission_denied") + assert.Equal(t, "$.['403_permission_denied']", path) + assert.Equal(t, "403_permission_denied", segment) + + // Nested path with digit-starting segment + segment, path = ConvertComponentIdIntoFriendlyPathSearch("#/responses/400_unexpected_request_body") + assert.Equal(t, "$.responses['400_unexpected_request_body']", path) + assert.Equal(t, "400_unexpected_request_body", segment) + + // Multiple digit-starting segments + segment, path = ConvertComponentIdIntoFriendlyPathSearch("#/4xx_errors/403_forbidden") + assert.Equal(t, "$.['4xx_errors']['403_forbidden']", path) + assert.Equal(t, "403_forbidden", segment) + + // Digit-starting in middle of path + segment, path = ConvertComponentIdIntoFriendlyPathSearch("#/components/responses/5xx_server_error/description") + assert.Equal(t, "$.components.responses['5xx_server_error'].description", path) + assert.Equal(t, "description", segment) + + // Pure numeric segment (handled by integer code path, uses [0] not ['0']) + segment, path = ConvertComponentIdIntoFriendlyPathSearch("#/items/0/name") + assert.Equal(t, "$.items[0].name", path) + assert.Equal(t, "name", segment) + + // Segment starting with digit but not pure number + segment, path = ConvertComponentIdIntoFriendlyPathSearch("#/2xx_success") + assert.Equal(t, "$.['2xx_success']", path) + assert.Equal(t, "2xx_success", segment) +} + +// Test isPathChar function directly for comprehensive coverage +func TestIsPathChar(t *testing.T) { + // Valid path characters (letters, numbers not at start, underscore, backslash) + assert.True(t, isPathChar("validName")) + assert.True(t, isPathChar("Valid123")) + assert.True(t, isPathChar("with_underscore")) + assert.True(t, isPathChar(`with\backslash`)) + assert.True(t, isPathChar("MixedCase123_test")) + + // Pure integers return true - they're handled separately as array indices + assert.True(t, isPathChar("0")) + assert.True(t, isPathChar("123")) + assert.True(t, isPathChar("99")) + + // Invalid: empty string + assert.False(t, isPathChar("")) + + // Invalid: starts with digit but NOT a pure integer (requires bracket notation in JSONPath) + assert.False(t, isPathChar("403_permission_denied")) + assert.False(t, isPathChar("4xx_errors")) + assert.False(t, isPathChar("123abc")) + assert.False(t, isPathChar("9_starts_with_nine")) + assert.False(t, isPathChar("0x123")) // hex-like but has 'x' + + // Invalid: contains special characters + assert.False(t, isPathChar("with-dash")) + assert.False(t, isPathChar("with space")) + assert.False(t, isPathChar("with@symbol")) + assert.False(t, isPathChar("with#hash")) + assert.False(t, isPathChar("with.dot")) +} + // Test documenting the defensive safeguard code behavior func TestConvertComponentIdIntoFriendlyPathSearch_DefensiveCodeDocumentation(t *testing.T) { // This test documents that the defensive safeguard code at lines 897-903 in ConvertComponentIdIntoFriendlyPathSearch