From d1b8c76f97a0ee67c404503584da779055bbc583 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 May 2025 17:25:22 +0000 Subject: [PATCH 1/5] Initial plan for issue From 015bc0338e4ff7924f2aca49bf6dcd9fe95768e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 May 2025 17:38:35 +0000 Subject: [PATCH 2/5] Implement OpenAPI input support Co-authored-by: hawkeyexl <5209367+hawkeyexl@users.noreply.github.com> --- src/openapi-integration.test.js | 112 ++++++++ src/openapi.js | 363 ++++++++++++++++++++++++- src/openapi.test.js | 215 +++++++++++++++ src/utils.js | 33 +++ test/openapi-test-example.json | 203 ++++++++++++++ test/openapi-test-with-extensions.json | 117 ++++++++ 6 files changed, 1041 insertions(+), 2 deletions(-) create mode 100644 src/openapi-integration.test.js create mode 100644 src/openapi.test.js create mode 100644 test/openapi-test-example.json create mode 100644 test/openapi-test-with-extensions.json diff --git a/src/openapi-integration.test.js b/src/openapi-integration.test.js new file mode 100644 index 0000000..ffd17b4 --- /dev/null +++ b/src/openapi-integration.test.js @@ -0,0 +1,112 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const path = require("path"); +const { detectTests } = require("./index"); + +describe("OpenAPI Integration Tests", () => { + let sandbox; + const minimalConfig = { + input: path.resolve(__dirname, "../test/openapi-test-example.json"), + environment: { + platform: "test" + }, + fileTypes: [] + }; + + const configWithExtensions = { + input: path.resolve(__dirname, "../test/openapi-test-with-extensions.json"), + environment: { + platform: "test" + }, + fileTypes: [] + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(console, "log"); // Mute console logs for tests + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should detect and parse OpenAPI files", async () => { + const result = await detectTests({ config: minimalConfig }); + + // Check that the OpenAPI file was processed + expect(result).to.be.an("array").that.has.lengthOf(1); + + // Verify the generated spec structure + const spec = result[0]; + expect(spec.specId).to.include("openapi-openapi-test-example"); + expect(spec.openApi).to.be.an("array").that.has.lengthOf(1); + expect(spec.openApi[0].name).to.equal("Test API"); + expect(spec.tests).to.be.an("array"); + + // We expect 3 tests (GET /users, POST /users, GET /users/{userId}, PUT /users/{userId} with safe override) + // DELETE is not included as it's unsafe + expect(spec.tests).to.have.lengthOf(4); + + // Verify each test has the expected format + spec.tests.forEach(test => { + expect(test.id).to.be.a("string"); + expect(test.description).to.be.a("string"); + expect(test.steps).to.be.an("array").that.has.lengthOf.at.least(1); + + // Each test should have an httpRequest step + const httpStep = test.steps[0]; + expect(httpStep.action).to.equal("httpRequest"); + expect(httpStep.openApi).to.be.an("object"); + expect(httpStep.openApi.operationId).to.be.a("string"); + }); + + // Find specific tests to verify + const getUsersTest = spec.tests.find(t => t.id === "getUsers"); + const createUserTest = spec.tests.find(t => t.id === "createUser"); + const getUserTest = spec.tests.find(t => t.id === "getUser"); + const updateUserTest = spec.tests.find(t => t.id === "updateUser"); + + expect(getUsersTest).to.exist; + expect(createUserTest).to.exist; + expect(getUserTest).to.exist; + expect(updateUserTest).to.exist; + }); + + it("should support x-doc-detective extensions", async () => { + const result = await detectTests({ config: configWithExtensions }); + + // Check that the OpenAPI file was processed + expect(result).to.be.an("array").that.has.lengthOf(1); + + // Verify the generated spec structure + const spec = result[0]; + expect(spec.specId).to.include("openapi-openapi-test-with-extensions"); + expect(spec.tests).to.be.an("array"); + + // We expect 3 tests (getProducts, createProduct, deleteProduct with safe override) + expect(spec.tests).to.have.lengthOf(3); + + // Find specific tests to verify + const getProductsTest = spec.tests.find(t => t.id === "getProducts"); + const createProductTest = spec.tests.find(t => t.id === "createProduct"); + const deleteProductTest = spec.tests.find(t => t.id === "deleteProduct"); + + expect(getProductsTest).to.exist; + expect(createProductTest).to.exist; + expect(deleteProductTest).to.exist; + + // Check that root extensions are applied + expect(getProductsTest.steps[0].openApi.server).to.equal("https://testing.example.com/v1"); + expect(getProductsTest.steps[0].openApi.validateSchema).to.equal(true); + + // Check that operation level overrides work + expect(createProductTest.steps[1].openApi.validateSchema).to.equal(false); + + // Check for dependencies (before and after) + expect(createProductTest.steps).to.have.lengthOf(2); + expect(createProductTest.steps[0].openApi.operationId).to.equal("getProducts"); + + expect(deleteProductTest.steps).to.have.lengthOf(2); + expect(deleteProductTest.steps[1].openApi.operationId).to.equal("getProducts"); + }); +}); \ No newline at end of file diff --git a/src/openapi.js b/src/openapi.js index 5d2fa9b..ece49ef 100644 --- a/src/openapi.js +++ b/src/openapi.js @@ -1,10 +1,42 @@ -const { replaceEnvs } = require("./utils"); const { JSONSchemaFaker } = require("json-schema-faker"); const { readFile } = require("doc-detective-common"); const parser = require("@apidevtools/json-schema-ref-parser"); +const path = require("path"); JSONSchemaFaker.option({ requiredOnly: true }); +// Helper function for environment variable replacement +function replaceEnvs(obj) { + if (typeof obj !== "object" || obj === null) return obj; + + const result = Array.isArray(obj) ? [] : {}; + + for (const key in obj) { + let value = obj[key]; + + if (typeof value === "string") { + // Replace environment variables in string values + const matches = value.match(/\$([a-zA-Z0-9_]+)/g); + if (matches) { + for (const match of matches) { + const envVar = match.substring(1); + const envValue = process.env[envVar]; + if (envValue !== undefined) { + value = value.replace(match, envValue); + } + } + } + } else if (typeof value === "object" && value !== null) { + // Recursively replace in nested objects/arrays + value = replaceEnvs(value); + } + + result[key] = value; + } + + return result; +} + /** * Dereferences an OpenAPI or Arazzo description * @@ -405,4 +437,331 @@ function checkForExamples(definition = {}, exampleKey = "") { return false; } -module.exports = { getOperation, loadDescription }; +/** + * Checks if a file is an OpenAPI 3.x specification. + * + * @param {Object} content - The file content to check. + * @param {String} filepath - The path to the file. + * @returns {Boolean} - True if the file is an OpenAPI 3.x specification, false otherwise. + */ +function isOpenApi3File(content, filepath) { + if (!content || typeof content !== "object") { + return false; + } + + // Check the file extension + const ext = path.extname(filepath).toLowerCase(); + if (![".json", ".yaml", ".yml"].includes(ext)) { + return false; + } + + // Check if it has the openapi field and it starts with "3." + if (content.openapi && content.openapi.startsWith("3.")) { + return true; + } + + return false; +} + +/** + * Transforms an OpenAPI document into a Doc Detective test specification. + * + * @param {Object} openApiDoc - The OpenAPI document. + * @param {String} filePath - Path to the original file. + * @param {Object} config - The configuration object. + * @returns {Object} - The Doc Detective test specification. + */ +function transformOpenApiToSpec(openApiDoc, filePath, config) { + // Create spec object + const id = `openapi-${path.basename(filePath, path.extname(filePath))}`; + const spec = { + specId: id, + contentPath: filePath, + tests: [], + openApi: [{ + name: openApiDoc.info?.title || id, + definition: openApiDoc + }] + }; + + // Extract operations + const operations = extractOperations(openApiDoc); + + // Create tests from operations + for (const operation of operations) { + try { + const test = transformOperationToTest(operation, openApiDoc, config); + if (test) { + spec.tests.push(test); + } + } catch (error) { + console.warn(`Error transforming operation ${operation.operationId}: ${error.message}`); + } + } + + return spec; +} + +/** + * Extracts operations from an OpenAPI document. + * + * @param {Object} openApiDoc - The OpenAPI document. + * @returns {Array} - Array of operation objects. + */ +function extractOperations(openApiDoc) { + const operations = []; + + // Get default configuration + const rootConfig = openApiDoc["x-doc-detective"] || {}; + + for (const path in openApiDoc.paths) { + for (const method in openApiDoc.paths[path]) { + // Skip non-operation fields like parameters + if (["parameters", "servers", "summary", "description"].includes(method)) { + continue; + } + + const operation = openApiDoc.paths[path][method]; + + // Add path and method to the operation + operation.path = path; + operation.method = method; + + // Merge x-doc-detective configurations + operation["x-doc-detective"] = { + ...(rootConfig || {}), + ...(operation["x-doc-detective"] || {}) + }; + + operations.push(operation); + } + } + + return operations; +} + +/** + * Determines if an operation is safe to execute automatically. + * + * @param {Object} operation - The operation to check. + * @returns {Boolean} - True if the operation is safe, false otherwise. + */ +function isOperationSafe(operation) { + // Check if operation has explicit safety configuration + if (operation["x-doc-detective"]?.safe !== undefined) { + return operation["x-doc-detective"].safe; + } + + // Default safety based on HTTP method + const safeMethods = ["get", "head", "options", "post"]; + return safeMethods.includes(operation.method.toLowerCase()); +} + +/** + * Transforms an OpenAPI operation into a Doc Detective test. + * + * @param {Object} operation - The OpenAPI operation. + * @param {Object} openApiDoc - The full OpenAPI document for resolving dependencies. + * @param {Object} config - The configuration object. + * @returns {Object} - The Doc Detective test. + */ +function transformOperationToTest(operation, openApiDoc, config) { + // Skip unsafe operations + if (!isOperationSafe(operation)) { + console.log(`Skipping unsafe operation: ${operation.operationId || operation.path}`); + return null; + } + + // Create test + const test = { + id: operation.operationId || `${operation.method}-${operation.path}`, + description: operation.summary || operation.description || `${operation.method} ${operation.path}`, + steps: [] + }; + + // Add before steps + const beforeSteps = createDependencySteps(operation["x-doc-detective"]?.before, openApiDoc); + if (beforeSteps) { + test.steps.push(...beforeSteps); + } + + // Add main operation step + const mainStep = createHttpRequestStep(operation); + test.steps.push(mainStep); + + // Add after steps + const afterSteps = createDependencySteps(operation["x-doc-detective"]?.after, openApiDoc); + if (afterSteps) { + test.steps.push(...afterSteps); + } + + return test; +} + +/** + * Creates an httpRequest step from an OpenAPI operation. + * + * @param {Object} operation - The OpenAPI operation. + * @returns {Object} - The httpRequest step. + */ +function createHttpRequestStep(operation) { + const step = { + action: "httpRequest", + openApi: {} + }; + + // Use operationId if available + if (operation.operationId) { + step.openApi.operationId = operation.operationId; + } else { + step.openApi.path = operation.path; + step.openApi.method = operation.method; + } + + // Add OpenAPI configuration from x-doc-detective + if (operation["x-doc-detective"]) { + const config = operation["x-doc-detective"]; + + // Add server if specified + if (config.server) { + step.openApi.server = config.server; + } + + // Add validateSchema if specified + if (config.validateSchema !== undefined) { + step.openApi.validateSchema = config.validateSchema; + } + + // Add mockResponse if specified + if (config.mockResponse !== undefined) { + step.openApi.mockResponse = config.mockResponse; + } + + // Add statusCodes if specified + if (config.statusCodes) { + step.openApi.statusCodes = config.statusCodes; + } + + // Add useExample if specified + if (config.useExample !== undefined) { + step.openApi.useExample = config.useExample; + } + + // Add exampleKey if specified + if (config.exampleKey) { + step.openApi.exampleKey = config.exampleKey; + } + + // Add requestHeaders if specified + if (config.requestHeaders) { + step.requestHeaders = config.requestHeaders; + } + + // Add responseHeaders if specified + if (config.responseHeaders) { + step.responseHeaders = config.responseHeaders; + } + } + + return step; +} + +/** + * Creates steps for dependency operations. + * + * @param {Array} dependencies - Array of operation dependencies. + * @param {Object} openApiDoc - The full OpenAPI document. + * @returns {Array} - Array of steps for the dependencies. + */ +function createDependencySteps(dependencies, openApiDoc) { + if (!dependencies || !dependencies.length) { + return null; + } + + const steps = []; + + for (const dep of dependencies) { + let operation; + + if (typeof dep === 'string') { + // If dependency is just a string, treat as operationId + operation = findOperationById(dep, openApiDoc); + } else if (dep.operationId) { + // If dependency has operationId + operation = findOperationById(dep.operationId, openApiDoc); + } else if (dep.path && dep.method) { + // If dependency has path and method + operation = findOperationByPath(dep.path, dep.method, openApiDoc); + } + + if (operation) { + steps.push(createHttpRequestStep(operation)); + } + } + + return steps; +} + +/** + * Finds an operation by its operationId. + * + * @param {String} operationId - The operationId to find. + * @param {Object} openApiDoc - The OpenAPI document to search. + * @returns {Object} - The operation if found, null otherwise. + */ +function findOperationById(operationId, openApiDoc) { + for (const path in openApiDoc.paths) { + for (const method in openApiDoc.paths[path]) { + if (["parameters", "servers", "summary", "description"].includes(method)) { + continue; + } + + const operation = openApiDoc.paths[path][method]; + + if (operation.operationId === operationId) { + operation.path = path; + operation.method = method; + return operation; + } + } + } + + return null; +} + +/** + * Finds an operation by path and method. + * + * @param {String} pathPattern - The path pattern to find. + * @param {String} method - The HTTP method. + * @param {Object} openApiDoc - The OpenAPI document to search. + * @returns {Object} - The operation if found, null otherwise. + */ +function findOperationByPath(pathPattern, method, openApiDoc) { + const normMethod = method.toLowerCase(); + + // Try exact match first + if (openApiDoc.paths[pathPattern] && openApiDoc.paths[pathPattern][normMethod]) { + const operation = openApiDoc.paths[pathPattern][normMethod]; + operation.path = pathPattern; + operation.method = normMethod; + return operation; + } + + // No match found + return null; +} + +module.exports = { + getOperation, + loadDescription, + isOpenApi3File, + transformOpenApiToSpec, + extractOperations, + transformOperationToTest, + isOperationSafe, + createHttpRequestStep, + createDependencySteps, + findOperationById, + findOperationByPath +}; diff --git a/src/openapi.test.js b/src/openapi.test.js new file mode 100644 index 0000000..624632b --- /dev/null +++ b/src/openapi.test.js @@ -0,0 +1,215 @@ +const { expect } = require("chai"); +const { + isOpenApi3File, + transformOpenApiToSpec, + extractOperations, + isOperationSafe, + transformOperationToTest +} = require("./openapi"); + +describe("OpenAPI Utilities", () => { + describe("isOpenApi3File", () => { + it("should return true for valid OpenAPI 3.x files", () => { + const validOpenApi = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0" + }, + paths: {} + }; + + expect(isOpenApi3File(validOpenApi, "test.json")).to.be.true; + expect(isOpenApi3File(validOpenApi, "test.yaml")).to.be.true; + expect(isOpenApi3File(validOpenApi, "test.yml")).to.be.true; + }); + + it("should return false for invalid OpenAPI files", () => { + const invalidOpenApi = { + swagger: "2.0", + info: { + title: "Test API", + version: "1.0.0" + }, + paths: {} + }; + + expect(isOpenApi3File(invalidOpenApi, "test.json")).to.be.false; + expect(isOpenApi3File(null, "test.json")).to.be.false; + expect(isOpenApi3File({}, "test.json")).to.be.false; + expect(isOpenApi3File(validOpenApi, "test.txt")).to.be.false; + }); + }); + + describe("extractOperations", () => { + it("should extract operations from OpenAPI document", () => { + const openApiDoc = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0" + }, + paths: { + "/users": { + get: { + operationId: "getUsers", + summary: "Get Users" + }, + post: { + operationId: "createUser", + summary: "Create User" + } + } + } + }; + + const operations = extractOperations(openApiDoc); + expect(operations).to.have.lengthOf(2); + expect(operations[0].operationId).to.equal("getUsers"); + expect(operations[0].path).to.equal("/users"); + expect(operations[0].method).to.equal("get"); + }); + + it("should merge x-doc-detective configurations", () => { + const openApiDoc = { + openapi: "3.0.0", + "x-doc-detective": { + safe: true, + server: "https://api.example.com" + }, + paths: { + "/users": { + get: { + operationId: "getUsers", + "x-doc-detective": { + validateSchema: true + } + } + } + } + }; + + const operations = extractOperations(openApiDoc); + expect(operations[0]["x-doc-detective"].safe).to.equal(true); + expect(operations[0]["x-doc-detective"].server).to.equal("https://api.example.com"); + expect(operations[0]["x-doc-detective"].validateSchema).to.equal(true); + }); + }); + + describe("isOperationSafe", () => { + it("should consider GET operations safe by default", () => { + const operation = { + method: "get" + }; + expect(isOperationSafe(operation)).to.be.true; + }); + + it("should consider POST operations safe by default", () => { + const operation = { + method: "post" + }; + expect(isOperationSafe(operation)).to.be.true; + }); + + it("should consider DELETE operations unsafe by default", () => { + const operation = { + method: "delete" + }; + expect(isOperationSafe(operation)).to.be.false; + }); + + it("should respect x-doc-detective.safe override", () => { + const unsafeGet = { + method: "get", + "x-doc-detective": { + safe: false + } + }; + expect(isOperationSafe(unsafeGet)).to.be.false; + + const safeDelete = { + method: "delete", + "x-doc-detective": { + safe: true + } + }; + expect(isOperationSafe(safeDelete)).to.be.true; + }); + }); + + describe("transformOperationToTest", () => { + it("should generate a proper test for safe operations", () => { + const operation = { + operationId: "getUsers", + method: "get", + path: "/users", + summary: "Get Users" + }; + + const test = transformOperationToTest(operation, {}, {}); + expect(test).to.be.an("object"); + expect(test.id).to.equal("getUsers"); + expect(test.description).to.equal("Get Users"); + expect(test.steps).to.be.an("array").that.has.lengthOf(1); + expect(test.steps[0].action).to.equal("httpRequest"); + expect(test.steps[0].openApi.operationId).to.equal("getUsers"); + }); + + it("should return null for unsafe operations", () => { + const operation = { + operationId: "deleteUser", + method: "delete", + path: "/users/{id}" + }; + + const test = transformOperationToTest(operation, {}, {}); + expect(test).to.be.null; + }); + }); + + describe("transformOpenApiToSpec", () => { + it("should transform OpenAPI document to Doc Detective test specification", () => { + const openApiDoc = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0" + }, + paths: { + "/users": { + get: { + operationId: "getUsers", + summary: "Get Users" + }, + post: { + operationId: "createUser", + summary: "Create User" + }, + delete: { + operationId: "deleteUsers", + summary: "Delete All Users" + } + } + } + }; + + const spec = transformOpenApiToSpec(openApiDoc, "test.json", {}); + expect(spec).to.be.an("object"); + expect(spec.specId).to.include("openapi-test"); + expect(spec.tests).to.be.an("array"); + expect(spec.tests).to.have.lengthOf(2); // Only GET and POST are safe + expect(spec.openApi).to.be.an("array").that.has.lengthOf(1); + expect(spec.openApi[0].definition).to.deep.equal(openApiDoc); + }); + }); +}); + +// Mock for global variable +const validOpenApi = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0" + }, + paths: {} +}; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index d73f67a..36308e9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -12,6 +12,7 @@ const { transformToSchemaKey, readFile, } = require("doc-detective-common"); +const { isOpenApi3File, transformOpenApiToSpec } = require("./openapi"); exports.qualifyFiles = qualifyFiles; exports.parseTests = parseTests; @@ -186,6 +187,12 @@ async function isValidSourceFile({ config, files, source }) { ); return false; } + + // Check if it's an OpenAPI file (we accept these regardless of validation) + if (isOpenApi3File(content, source)) { + return true; + } + const validation = validate({ schemaKey: "spec_v3", object: content, @@ -546,6 +553,32 @@ async function parseTests({ config, files }) { content = await readFile({ fileURLOrPath: file }); if (typeof content === "object") { + // Check if it's an OpenAPI file + if (isOpenApi3File(content, file)) { + log(config, "info", `Detected OpenAPI 3.x file: ${file}`); + + try { + // Transform OpenAPI to Doc Detective test specification + const openApiSpec = transformOpenApiToSpec(content, file, config); + if (openApiSpec && openApiSpec.tests && openApiSpec.tests.length > 0) { + specs.push(openApiSpec); + } else { + log( + config, + "warning", + `No valid tests could be generated from OpenAPI file: ${file}` + ); + } + } catch (error) { + log( + config, + "error", + `Failed to transform OpenAPI file ${file}: ${error.message}` + ); + } + continue; + } + // Resolve to catch any relative setup or cleanup paths content = await resolvePaths({ config: config, diff --git a/test/openapi-test-example.json b/test/openapi-test-example.json new file mode 100644 index 0000000..b93d5ec --- /dev/null +++ b/test/openapi-test-example.json @@ -0,0 +1,203 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0", + "description": "A simple API for testing OpenAPI input support" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "summary": "Get all users", + "responses": { + "200": { + "description": "List of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "createUser", + "summary": "Create a user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/users/{userId}": { + "get": { + "operationId": "getUser", + "summary": "Get a user by ID", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + }, + "put": { + "operationId": "updateUser", + "summary": "Update a user", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "x-doc-detective": { + "safe": true + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete a user", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "User deleted successfully" + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/test/openapi-test-with-extensions.json b/test/openapi-test-with-extensions.json new file mode 100644 index 0000000..bea93f1 --- /dev/null +++ b/test/openapi-test-with-extensions.json @@ -0,0 +1,117 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API with Extensions", + "version": "1.0.0", + "description": "A test API with Doc Detective extensions" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "x-doc-detective": { + "server": "https://testing.example.com/v1", + "validateSchema": true, + "mockResponse": false + }, + "paths": { + "/products": { + "get": { + "operationId": "getProducts", + "summary": "Get all products", + "responses": { + "200": { + "description": "List of products", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + } + } + } + } + } + }, + "post": { + "operationId": "createProduct", + "summary": "Create a product", + "x-doc-detective": { + "before": ["getProducts"], + "validateSchema": false + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + }, + "responses": { + "201": { + "description": "Created product", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + } + } + } + }, + "/products/{productId}": { + "delete": { + "operationId": "deleteProduct", + "summary": "Delete a product", + "x-doc-detective": { + "safe": true, + "after": ["getProducts"] + }, + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Product deleted successfully" + } + } + } + } + }, + "components": { + "schemas": { + "Product": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "price": { + "type": "number" + }, + "category": { + "type": "string" + } + }, + "required": ["name", "price"] + } + } + } +} \ No newline at end of file From e5f86a813a1943d6e8ab935b9833ca08131f8fed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 May 2025 18:11:14 +0000 Subject: [PATCH 3/5] Remove 'safe' property from x-doc-detective extension Co-authored-by: hawkeyexl <5209367+hawkeyexl@users.noreply.github.com> --- src/openapi-integration.test.js | 4 ++-- src/openapi.js | 6 +++--- src/openapi.test.js | 16 +++------------- test/openapi-test-example.json | 4 +--- test/openapi-test-with-extensions.json | 1 - 5 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/openapi-integration.test.js b/src/openapi-integration.test.js index ffd17b4..74bdb47 100644 --- a/src/openapi-integration.test.js +++ b/src/openapi-integration.test.js @@ -44,8 +44,8 @@ describe("OpenAPI Integration Tests", () => { expect(spec.tests).to.be.an("array"); // We expect 3 tests (GET /users, POST /users, GET /users/{userId}, PUT /users/{userId} with safe override) - // DELETE is not included as it's unsafe - expect(spec.tests).to.have.lengthOf(4); + // DELETE is now included because it has an x-doc-detective extension + expect(spec.tests).to.have.lengthOf(5); // Verify each test has the expected format spec.tests.forEach(test => { diff --git a/src/openapi.js b/src/openapi.js index ece49ef..096f22e 100644 --- a/src/openapi.js +++ b/src/openapi.js @@ -547,9 +547,9 @@ function extractOperations(openApiDoc) { * @returns {Boolean} - True if the operation is safe, false otherwise. */ function isOperationSafe(operation) { - // Check if operation has explicit safety configuration - if (operation["x-doc-detective"]?.safe !== undefined) { - return operation["x-doc-detective"].safe; + // Operations with x-doc-detective configured are considered safe + if (operation["x-doc-detective"]) { + return true; } // Default safety based on HTTP method diff --git a/src/openapi.test.js b/src/openapi.test.js index 624632b..84dc71b 100644 --- a/src/openapi.test.js +++ b/src/openapi.test.js @@ -118,20 +118,10 @@ describe("OpenAPI Utilities", () => { expect(isOperationSafe(operation)).to.be.false; }); - it("should respect x-doc-detective.safe override", () => { - const unsafeGet = { - method: "get", - "x-doc-detective": { - safe: false - } - }; - expect(isOperationSafe(unsafeGet)).to.be.false; - + it("should consider operations with x-doc-detective safe regardless of method", () => { const safeDelete = { method: "delete", - "x-doc-detective": { - safe: true - } + "x-doc-detective": {} }; expect(isOperationSafe(safeDelete)).to.be.true; }); @@ -197,7 +187,7 @@ describe("OpenAPI Utilities", () => { expect(spec).to.be.an("object"); expect(spec.specId).to.include("openapi-test"); expect(spec.tests).to.be.an("array"); - expect(spec.tests).to.have.lengthOf(2); // Only GET and POST are safe + expect(spec.tests).to.have.lengthOf(3); // All operations with x-doc-detective are considered safe now expect(spec.openApi).to.be.an("array").that.has.lengthOf(1); expect(spec.openApi[0].definition).to.deep.equal(openApiDoc); }); diff --git a/test/openapi-test-example.json b/test/openapi-test-example.json index b93d5ec..aa4714b 100644 --- a/test/openapi-test-example.json +++ b/test/openapi-test-example.json @@ -126,9 +126,7 @@ } } ], - "x-doc-detective": { - "safe": true - }, + "x-doc-detective": {}, "requestBody": { "content": { "application/json": { diff --git a/test/openapi-test-with-extensions.json b/test/openapi-test-with-extensions.json index bea93f1..3560d87 100644 --- a/test/openapi-test-with-extensions.json +++ b/test/openapi-test-with-extensions.json @@ -71,7 +71,6 @@ "operationId": "deleteProduct", "summary": "Delete a product", "x-doc-detective": { - "safe": true, "after": ["getProducts"] }, "parameters": [ From 1d6ec5bef93b7baffcf61fce32c0b2f078456919 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 May 2025 19:12:09 +0000 Subject: [PATCH 4/5] Add OpenAPI YAML test example and integration test Co-authored-by: hawkeyexl <5209367+hawkeyexl@users.noreply.github.com> --- src/openapi-yaml-integration.test.js | 77 +++++++++++++++ test/openapi-test-example.yaml | 138 +++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 src/openapi-yaml-integration.test.js create mode 100644 test/openapi-test-example.yaml diff --git a/src/openapi-yaml-integration.test.js b/src/openapi-yaml-integration.test.js new file mode 100644 index 0000000..98c015d --- /dev/null +++ b/src/openapi-yaml-integration.test.js @@ -0,0 +1,77 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const path = require("path"); +const { detectTests } = require("./index"); + +describe("OpenAPI YAML Integration Tests", () => { + let sandbox; + const yamlConfig = { + input: path.resolve(__dirname, "../test/openapi-test-example.yaml"), + environment: { + platform: "test" + }, + fileTypes: [] + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(console, "log"); // Mute console logs for tests + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should detect and parse OpenAPI YAML files", async () => { + const result = await detectTests({ config: yamlConfig }); + + // Check that the OpenAPI file was processed + expect(result).to.be.an("array").that.has.lengthOf(1); + + // Verify the generated spec structure + const spec = result[0]; + expect(spec.specId).to.include("openapi-openapi-test-example"); + expect(spec.openApi).to.be.an("array").that.has.lengthOf(1); + expect(spec.openApi[0].name).to.equal("YAML Test API"); + expect(spec.tests).to.be.an("array"); + + // We expect tests for all operations (GET /pets, POST /pets, GET /pets/{petId}, DELETE /pets/{petId}) + expect(spec.tests).to.have.lengthOf(4); + + // Verify each test has the expected format + spec.tests.forEach(test => { + expect(test.id).to.be.a("string"); + expect(test.description).to.be.a("string"); + expect(test.steps).to.be.an("array").that.has.lengthOf.at.least(1); + + // Each test should have an httpRequest step + const httpStep = test.steps.find(step => step.action === "httpRequest"); + expect(httpStep).to.exist; + expect(httpStep.action).to.equal("httpRequest"); + expect(httpStep.openApi).to.be.an("object"); + }); + + // Find specific tests to verify + const listPetsTest = spec.tests.find(t => t.id === "listPets"); + const createPetTest = spec.tests.find(t => t.id === "createPet"); + const getPetTest = spec.tests.find(t => t.id === "getPet"); + const deletePetTest = spec.tests.find(t => t.id === "deletePet"); + + expect(listPetsTest).to.exist; + expect(createPetTest).to.exist; + expect(getPetTest).to.exist; + expect(deletePetTest).to.exist; + + // Check root level x-doc-detective configuration is applied + expect(listPetsTest.steps[0].openApi.server).to.equal("https://test-server.example.com"); + expect(listPetsTest.steps[0].openApi.validateSchema).to.equal(true); + + // Check operation-specific overrides work + expect(createPetTest.steps.length).to.equal(2); // Main step + before steps + expect(createPetTest.steps[1].openApi.validateSchema).to.equal(false); // Override from operation + + // Check for dependencies (before and after) + expect(createPetTest.steps[0].openApi.operationId).to.equal("listPets"); + expect(deletePetTest.steps[1].openApi.operationId).to.equal("listPets"); + }); +}); \ No newline at end of file diff --git a/test/openapi-test-example.yaml b/test/openapi-test-example.yaml new file mode 100644 index 0000000..bc9c4ae --- /dev/null +++ b/test/openapi-test-example.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.0 +info: + title: "YAML Test API" + version: "1.0.0" + description: "A simple API for testing OpenAPI YAML input support" +servers: + - url: "https://api.example.com/v1" +x-doc-detective: + server: "https://test-server.example.com" + validateSchema: true +paths: + /pets: + get: + operationId: listPets + summary: "List all pets" + parameters: + - name: limit + in: query + description: "Maximum number of pets to return" + required: false + schema: + type: integer + format: int32 + example: 10 + responses: + '200': + description: "A list of pets" + content: + application/json: + schema: + type: object + properties: + pets: + type: array + items: + $ref: '#/components/schemas/Pet' + pagination: + $ref: '#/components/schemas/Pagination' + post: + operationId: createPet + summary: "Create a pet" + x-doc-detective: + before: ["listPets"] + validateSchema: false + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + example: + name: "Fluffy" + type: "cat" + age: 3 + responses: + '201': + description: "Pet created" + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + example: + id: "pet-123" + name: "Fluffy" + type: "cat" + age: 3 + /pets/{petId}: + get: + operationId: getPet + summary: "Get a pet by ID" + parameters: + - name: petId + in: path + description: "ID of the pet to get" + required: true + schema: + type: string + example: "pet-123" + responses: + '200': + description: "Pet details" + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + delete: + operationId: deletePet + summary: "Delete a pet" + x-doc-detective: + after: ["listPets"] + parameters: + - name: petId + in: path + description: "ID of the pet to delete" + required: true + schema: + type: string + example: "pet-123" + responses: + '204': + description: "Pet deleted" +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string + type: + type: string + enum: ["dog", "cat", "bird"] + age: + type: integer + NewPet: + type: object + required: + - name + properties: + name: + type: string + type: + type: string + enum: ["dog", "cat", "bird"] + age: + type: integer + Pagination: + type: object + properties: + total: + type: integer + page: + type: integer + limit: + type: integer \ No newline at end of file From a2512a89d8cc77106099fcc0fed72f0bec55163b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:24:56 +0000 Subject: [PATCH 5/5] Add comprehensive OpenAPI transformation documentation Co-authored-by: hawkeyexl <5209367+hawkeyexl@users.noreply.github.com> --- README.md | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/README.md b/README.md index 6c0ad4e..09e48c5 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,268 @@ const detectedTests = await detectTests({ config }); const resolvedTests = await resolveTests({ config, detectedTests }); ``` +## OpenAPI 3.x Support + +Doc Detective Resolver includes built-in support for OpenAPI 3.x specifications, automatically transforming API operations into executable test specifications. This enables seamless integration of API documentation with automated testing workflows. + +### Detection and File Support + +OpenAPI files are automatically detected based on: + +1. **File Extension**: Must be `.json`, `.yaml`, or `.yml` +2. **OpenAPI Version**: Must contain `openapi: "3.x.x"` field (any 3.x version) + +When a file meets these criteria, it's processed as an OpenAPI specification instead of a standard Doc Detective test file. + +### Transformation Process + +The transformation from OpenAPI operations to Doc Detective tests follows this workflow: + +#### 1. **Operation Extraction** +- All HTTP operations (GET, POST, PUT, PATCH, DELETE, etc.) are extracted from the `paths` section +- Each operation is evaluated for safety and configuration +- Non-operation fields like `parameters`, `servers`, `summary`, and `description` at the path level are skipped + +#### 2. **Safety Classification** +Operations are classified as "safe" or "unsafe" based on the following rules: + +**Safe Operations (automatically included):** +- Operations with `x-doc-detective` extension (any configuration makes them explicitly safe) +- GET, HEAD, OPTIONS, POST methods (considered safe by default) + +**Unsafe Operations (skipped unless explicitly marked):** +- PUT, PATCH, DELETE methods without `x-doc-detective` extension +- Any operation that could modify or delete data without explicit safety confirmation + +#### 3. **Test Generation** +For each safe operation, a Doc Detective test is created with: + +- **Test ID**: Uses `operationId` if available, otherwise generates `{method}-{path}` +- **Description**: Uses operation `summary`, `description`, or generates from method and path +- **Main Step**: Creates an `httpRequest` step with OpenAPI configuration +- **Dependencies**: Adds `before` and `after` steps if configured + +### x-doc-detective Extensions + +The `x-doc-detective` extension provides fine-grained control over test generation and execution: + +#### Root-Level Configuration +Applied to all operations as default values: + +```yaml +openapi: 3.0.0 +x-doc-detective: + server: "https://testing.example.com" + validateSchema: true + statusCodes: [200, 201] +``` + +#### Operation-Level Configuration +Overrides root-level settings for specific operations: + +```yaml +paths: + /users/{id}: + delete: + x-doc-detective: + validateSchema: false + before: ["getUser"] + after: ["getAllUsers"] +``` + +#### Supported Extension Properties + +| Property | Type | Description | +|----------|------|-------------| +| `server` | string | Base URL for API requests (overrides OpenAPI servers) | +| `validateSchema` | boolean | Enable/disable response schema validation | +| `mockResponse` | boolean | Use mock responses instead of real API calls | +| `statusCodes` | array | Expected HTTP status codes for the operation | +| `useExample` | boolean | Use OpenAPI examples in requests | +| `exampleKey` | string | Specific example key to use from OpenAPI examples | +| `requestHeaders` | object | Additional headers to include in requests | +| `responseHeaders` | object | Expected response headers to validate | +| `before` | array | Operations to execute before this operation | +| `after` | array | Operations to execute after this operation | + +### Dependency Management + +Dependencies enable complex testing workflows by chaining operations: + +#### Before Dependencies +Execute prerequisite operations before the main operation: + +```yaml +post: + operationId: createUser + x-doc-detective: + before: ["loginUser", "getPermissions"] +``` + +#### After Dependencies +Execute cleanup or verification operations after the main operation: + +```yaml +delete: + operationId: deleteUser + x-doc-detective: + after: ["verifyUserDeleted", "cleanupSession"] +``` + +#### Dependency Resolution +Dependencies can be referenced by: +- **Operation ID**: `"getUserById"` +- **Path + Method**: `{"path": "/users/{id}", "method": "get"}` + +### Configuration Inheritance + +Configuration values are merged in the following priority order (highest to lowest): + +1. Operation-level `x-doc-detective` configuration +2. Root-level `x-doc-detective` configuration +3. Doc Detective global configuration +4. Default values + +### Transformation Examples + +#### Input: Basic OpenAPI Operation +```yaml +openapi: 3.0.0 +paths: + /users: + get: + operationId: getUsers + summary: "Retrieve all users" + responses: + 200: + description: "List of users" +``` + +#### Output: Generated Doc Detective Test +```json +{ + "specId": "openapi-example", + "tests": [ + { + "id": "getUsers", + "description": "Retrieve all users", + "steps": [ + { + "action": "httpRequest", + "openApi": { + "operationId": "getUsers" + } + } + ] + } + ], + "openApi": [ + { + "name": "Example API", + "definition": { /* full OpenAPI spec */ } + } + ] +} +``` + +#### Input: Complex Operation with Dependencies +```yaml +paths: + /users/{id}: + delete: + operationId: deleteUser + x-doc-detective: + server: "https://test.api.com" + before: ["getUser", "backupUser"] + after: ["verifyDeleted"] + statusCodes: [204, 404] +``` + +#### Output: Generated Test with Dependencies +```json +{ + "id": "deleteUser", + "steps": [ + { + "action": "httpRequest", + "openApi": { "operationId": "getUser" } + }, + { + "action": "httpRequest", + "openApi": { "operationId": "backupUser" } + }, + { + "action": "httpRequest", + "openApi": { + "operationId": "deleteUser", + "server": "https://test.api.com", + "statusCodes": [204, 404] + } + }, + { + "action": "httpRequest", + "openApi": { "operationId": "verifyDeleted" } + } + ] +} +``` + +### Requirements and Behaviors + +#### Method-Specific Requirements +- **GET/HEAD/OPTIONS**: No special requirements, considered safe by default +- **POST**: Considered safe for creation operations, no additional requirements +- **PUT/PATCH/DELETE**: Require explicit `x-doc-detective` extension to be included + +#### Validation Requirements +- OpenAPI specification must be valid 3.x format +- Referenced dependencies must exist in the same OpenAPI specification +- If `validateSchema: true`, operations must have complete request/response schemas + +#### Server Configuration +- If no `server` specified in `x-doc-detective`, uses first server from OpenAPI `servers` array +- If no servers defined anywhere, transformation will fail with error +- Server URLs support environment variable substitution: `https://$API_HOST/v1` + +### User Expectations + +#### What Gets Generated +- **One test per safe operation**: Each operation becomes a separate test +- **Automatic request/response handling**: Full HTTP request steps with OpenAPI context +- **Schema validation**: Automatic request/response validation when enabled +- **Dependency orchestration**: Before/after operations executed in correct order + +#### What Doesn't Get Generated +- **Unsafe operations**: PUT/PATCH/DELETE without explicit `x-doc-detective` are skipped +- **Path-level parameters**: Only operation-level configurations are processed +- **Custom test logic**: Only `httpRequest` steps are generated, no custom actions +- **Complex workflows**: Each operation is a separate test, not part of larger workflows + +#### Error Handling +- **Invalid OpenAPI**: Files that don't validate as OpenAPI 3.x are skipped +- **Missing dependencies**: Referenced operations that don't exist log warnings but don't fail the transformation +- **Schema errors**: Operations with invalid schemas log warnings but are still included +- **Server resolution**: Missing server configuration causes transformation to fail + +### Integration with Doc Detective + +Generated tests integrate seamlessly with the Doc Detective ecosystem: + +- **Schema validation**: Uses Doc Detective's OpenAPI schema validation +- **Variable substitution**: Supports Doc Detective variable replacement patterns +- **Context resolution**: Automatically detects browser context requirements +- **Result reporting**: Test results use standard Doc Detective output format + +### Best Practices + +1. **Use meaningful operationIds**: They become test IDs and should be descriptive +2. **Include summaries/descriptions**: They become test descriptions for better reporting +3. **Configure servers appropriately**: Use test/staging servers, not production +4. **Mark destructive operations explicitly**: Use `x-doc-detective` on PUT/PATCH/DELETE +5. **Test dependencies carefully**: Ensure `before`/`after` operations exist and are safe +6. **Use environment variables**: Keep sensitive data out of OpenAPI files +7. **Validate your OpenAPI**: Use tools to ensure your specification is valid before testing + ## Contributions Looking to help out? See our [contributions guide](https://github.com/doc-detective/doc-detective-resolver/blob/main/CONTRIBUTIONS.md) for more info. If you can't contribute code, you can still help by reporting issues, suggesting new features, improving the documentation, or sponsoring the project.