diff --git a/bundler/external_component_ref_test.go b/bundler/external_component_ref_test.go new file mode 100644 index 00000000..30e73324 --- /dev/null +++ b/bundler/external_component_ref_test.go @@ -0,0 +1,972 @@ +// 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) +} diff --git a/datamodel/high/base/example.go b/datamodel/high/base/example.go index 1dc1595c..4133e4a8 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 @@ -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, _ := high.RenderExternalRef(e.low, buildLowExample, NewExample) + if rendered != nil { + return rendered, nil + } + } + 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..f2e7b11b 100644 --- a/datamodel/high/base/example_test.go +++ b/datamodel/high/base/example_test.go @@ -263,3 +263,36 @@ 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) +} diff --git a/datamodel/high/shared.go b/datamodel/high/shared.go index 1f8d0808..fcccfc20 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,117 @@ 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 { + return nil, err + } + if !result.Resolved { + return nil, nil + } + 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 { + return nil, err + } + if !result.Resolved { + return nil, nil + } + 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..9cf6d418 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..ab73eb74 100644 --- a/datamodel/high/v3/callback_test.go +++ b/datamodel/high/v3/callback_test.go @@ -228,3 +228,43 @@ 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) + } +} diff --git a/datamodel/high/v3/header.go b/datamodel/high/v3/header.go index afbb3cc1..a8aa21c1 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,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + return high.RenderInline(h, h.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Header object, @@ -124,10 +147,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + 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..9a9f183f 100644 --- a/datamodel/high/v3/header_test.go +++ b/datamodel/high/v3/header_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" @@ -198,3 +199,36 @@ 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) +} 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..dae30b80 100644 --- a/datamodel/high/v3/link_test.go +++ b/datamodel/high/v3/link_test.go @@ -153,3 +153,35 @@ 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) +} diff --git a/datamodel/high/v3/parameter.go b/datamodel/high/v3/parameter.go index 25916d54..d5adc803 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,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + return high.RenderInline(p, p.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Parameter object, @@ -137,10 +161,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + 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..87b612f8 100644 --- a/datamodel/high/v3/parameter_test.go +++ b/datamodel/high/v3/parameter_test.go @@ -370,3 +370,45 @@ 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) +} diff --git a/datamodel/high/v3/path_item.go b/datamodel/high/v3/path_item.go index d39109cd..48c668ce 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,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } - 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 +301,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + 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..f6e4ef63 100644 --- a/datamodel/high/v3/path_item_test.go +++ b/datamodel/high/v3/path_item_test.go @@ -522,3 +522,42 @@ 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) + } +} 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..9e6a622e 100644 --- a/datamodel/high/v3/request_body_test.go +++ b/datamodel/high/v3/request_body_test.go @@ -197,3 +197,42 @@ 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) +} + diff --git a/datamodel/high/v3/response.go b/datamodel/high/v3/response.go index f63002ab..6c642ac8 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,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + return high.RenderInline(r, r.low) } // MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Response object, @@ -107,10 +131,19 @@ 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 { + return nil, err + } + if rendered != nil { + return rendered, nil + } + } + + 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..d868c3ac 100644 --- a/datamodel/high/v3/response_test.go +++ b/datamodel/high/v3/response_test.go @@ -222,3 +222,41 @@ 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) +} 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..1d608f51 100644 --- a/datamodel/high/v3/security_scheme_test.go +++ b/datamodel/high/v3/security_scheme_test.go @@ -157,3 +157,36 @@ 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) +} 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