diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ace168..74acfc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.4.1-beta-1] - 2026-06-26 + +### Fixed +- Resolve external `$ref` (HTTP/HTTPS URLs) instead of crashing with `TypeError`. External schemas are fetched, cached, and inlined at parse time using Node's built-in `http`/`https` modules. + + ## [2.4.0] - 2026-05-07 ### Added diff --git a/index.js b/index.js index a378f2a..203cf6c 100644 --- a/index.js +++ b/index.js @@ -66,12 +66,13 @@ try { require('./src/utils/error.js')('use argv: -c/--configuration. configuration file path does not exist or is not correct: ' + argv.configuration); } +;(async () => { global.definition = require('./src/parser/definition.js')() const version = require('./src/parser/version.js')() global.environmentVariables = {} global.testParams = {} global.configurationFile = configurationFile -require('./src/parser/'+version+'/refs.js')() +await require('./src/parser/'+version+'/refs.js')() const schemaHostBasePath = require('./src/parser/'+version+'/schemaHostBasePath.js')() const endpointsParsed = require('./src/parser/endpoints.js')() @@ -231,8 +232,8 @@ _.forEach(environments, function (element) { } if ( element.custom_authorizations_file ) { require('./src/parser/authorizationRequests.js')(endpointsStage,element.custom_authorizations_file) - } else if(global.definition.components.securitySchemes){ - let securityDefinition = require('./src/parser/openapiAuthorizationDefinition.js')(global.definition.components.securitySchemes) + } else if(globalThis.definition.components?.securitySchemes){ + let securityDefinition = require('./src/parser/openapiAuthorizationDefinition.js')(globalThis.definition.components.securitySchemes) if(securityDefinition){ require('./src/parser/authorizationRequests.js')(endpointsStage,null,securityDefinition) } @@ -299,10 +300,13 @@ function addLettersToName(collection) { // Añade una letra al nombre de cada Test Case, justo despues del status code. Ej.: 200a OK // Controla el exceso de Test Cases y añade dos letras en caso de ser necesario. Ej.: 200aa OK, 200ab OK for (let k in array) { - array[k].name = _.replace(array[k].name, array[k].aux.status, + array[k].name = _.replace(array[k].name, array[k].aux.status, k < alphabet.length ? array[k].aux.status + alphabet[k] : array[k].aux.status + alphabet[Math.floor(k / alphabet.length) - 1] + alphabet[k % alphabet.length]); } } } } -} \ No newline at end of file +} +})().catch(err => { + require('./src/utils/error.js')(err.message || String(err)) +}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4cd3e68..e8dffa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openapi2postman", - "version": "2.4.0", + "version": "2.4.1-beta-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openapi2postman", - "version": "2.4.0", + "version": "2.4.1-beta-1", "license": "ISC", "dependencies": { "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 1ccfcf3..0b05d30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openapi2postman", - "version": "2.4.0", + "version": "2.4.1-beta-1", "description": "openapi2postman", "bin": { "o2p": "index.js" diff --git a/seeds/parserInitialGoodOpenApi3.json b/seeds/parserInitialGoodOpenApi3.json index 6e46152..999350d 100644 --- a/seeds/parserInitialGoodOpenApi3.json +++ b/seeds/parserInitialGoodOpenApi3.json @@ -9,7 +9,7 @@ }, "servers":[ { - "url":"http://petstore.swagger.io/v1" + "url":"https://petstore.swagger.io/v1" } ], "paths":{ diff --git a/src/parser/openapi3/refs.js b/src/parser/openapi3/refs.js index 26ecd3d..6daa70e 100644 --- a/src/parser/openapi3/refs.js +++ b/src/parser/openapi3/refs.js @@ -2,36 +2,18 @@ 'use strict' -function eachRecursive(obj) { - for (let k in obj) { - if (typeof obj[k] == "object" && obj[k] !== null) { - eachRecursive(obj[k]); - } else if (k == '$ref') { - const siblings = {} - for (const sib of Object.keys(obj)) { - if (sib !== '$ref') { - siblings[sib] = obj[sib] - } - } - let property = obj[k] - property = property.replace('#/', '') - let propertiesArray = property.split('/') - let refObject = findObject(globalThis.definition, propertiesArray) - delete obj[k] - Object.assign(obj, refObject, siblings) - } - } -} +const createRefsModule = require('../refs') -function findObject(obj, propertiesArray) { - if(propertiesArray.length < 1) { - return obj +function collectSiblings(obj) { + const siblings = {} + for (const sib of Object.keys(obj)) { + if (sib !== '$ref') siblings[sib] = obj[sib] } - - let property = propertiesArray.shift() - return findObject(obj[property], propertiesArray) + return siblings } +const resolveRefs = createRefsModule({ prepareSiblings: collectSiblings }) + function liftAdditionalOperations() { if (!globalThis.definition.paths) return for (const path in globalThis.definition.paths) { @@ -46,9 +28,9 @@ function liftAdditionalOperations() { } module.exports = function() { - return function get() { - eachRecursive(globalThis.definition) + return async function get() { + await resolveRefs(globalThis.definition, globalThis.definition) liftAdditionalOperations() return globalThis.definition } -}() \ No newline at end of file +}() diff --git a/src/parser/refs.js b/src/parser/refs.js new file mode 100644 index 0000000..2105501 --- /dev/null +++ b/src/parser/refs.js @@ -0,0 +1,103 @@ +/** Part of APIAddicts. See LICENSE fileor full copyright and licensing details. Supported by Madrid Digital and CloudAPPi **/ + +'use strict' + +const https = require('node:https') +const http = require('node:http') +const yaml = require('js-yaml') +const error = require('../utils/error') + +function parseYamlResponse(data, url, resolve, reject) { + try { resolve(yaml.load(data)) } + catch (e) { reject(new Error('Failed to parse external ref from ' + url + ': ' + e.message)) } +} + +function fetchUrl(url) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https://') ? https : http + client.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return fetchUrl(res.headers.location).then(resolve, reject) + } + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume() + return reject(new Error('Failed to fetch external ref ' + url + ': HTTP ' + res.statusCode)) + } + let data = '' + res.on('data', chunk => data += chunk) + res.on('end', () => parseYamlResponse(data, url, resolve, reject)) + }).on('error', e => reject(new Error('Failed to fetch external ref ' + url + ': ' + e.message))) + }) +} + +function extractFragment(refValue) { + const hashIdx = refValue.indexOf('#') + return { + baseUrl: hashIdx >= 0 ? refValue.substring(0, hashIdx) : refValue, + fragment: hashIdx >= 0 ? refValue.substring(hashIdx + 1) : '' + } +} + +function findObject(obj, propertiesArray) { + if (propertiesArray.length < 1) return obj + if (obj === undefined || obj === null) return undefined + const property = propertiesArray.shift() + return findObject(obj[property], propertiesArray) +} + +function isExternalRef(refValue) { + return refValue.startsWith('http://') || refValue.startsWith('https://') +} + +function resolveInternalRef(refValue, localDefinition) { + const fragment = refValue.replace(/^#\/?/, '') + const parts = fragment ? fragment.split('/') : [] + const refObject = parts.length ? findObject(localDefinition, [...parts]) : localDefinition + if (refObject === undefined) { error('$ref not found: ' + refValue) } + return refObject +} + +module.exports = function createRefsModule(opts) { + const prepareSiblings = opts?.prepareSiblings || function() { return {} } + const externalDocCache = new Map() + + async function ensureFetched(baseUrl, seenUrls) { + if (externalDocCache.has(baseUrl)) return + if (seenUrls.has(baseUrl)) { error('Circular external $ref detected: ' + baseUrl) } + const childSeen = new Set(seenUrls) + childSeen.add(baseUrl) + let doc + try { doc = await fetchUrl(baseUrl) } + catch (e) { error(e.message) } + await resolveRefs(doc, doc, childSeen) + externalDocCache.set(baseUrl, doc) + } + + async function fetchAndResolveExternal(refValue, seenUrls) { + const { baseUrl, fragment } = extractFragment(refValue) + await ensureFetched(baseUrl, seenUrls) + const doc = externalDocCache.get(baseUrl) + if (!fragment || fragment === '/') return doc + const parts = fragment.replace(/^\//, '').split('/') + const resolved = findObject(doc, parts) + if (resolved === undefined) { error('External $ref fragment not found: ' + fragment + ' in ' + baseUrl) } + return resolved + } + + async function resolveRefs(obj, localDefinition, seenUrls = new Set()) { + for (const k in obj) { + if (typeof obj[k] === 'object' && obj[k] !== null) { + await resolveRefs(obj[k], localDefinition, seenUrls) + } else if (k === '$ref') { + const siblings = prepareSiblings(obj) + const refObject = isExternalRef(obj[k]) + ? await fetchAndResolveExternal(obj[k], seenUrls) + : resolveInternalRef(obj[k], localDefinition) + delete obj[k] + Object.assign(obj, refObject, siblings) + } + } + } + + return resolveRefs +} diff --git a/src/parser/swagger2/refs.js b/src/parser/swagger2/refs.js index 6654c80..d9ed935 100644 --- a/src/parser/swagger2/refs.js +++ b/src/parser/swagger2/refs.js @@ -2,39 +2,13 @@ 'use strict' -function eachRecursive(obj) { - for (var k in obj) { - if (typeof obj[k] == "object" && obj[k] !== null) { - eachRecursive(obj[k]); - } else { - if(k == '$ref') { - let property = obj[k] - property = property.replace('#/', '') - let propertiesArray = property.split('/') - let refObject = findObject(global.definition, propertiesArray) +const createRefsModule = require('../refs') - // Clear ref property - delete obj[k] - - // Assign properties refOcject - Object.assign(obj, refObject) - } - } - } -} - -function findObject(obj, propertiesArray) { - if(propertiesArray.length < 1) { - return obj - } - - let property = propertiesArray.shift() - return findObject(obj[property], propertiesArray) -} +const resolveRefs = createRefsModule() module.exports = function() { - return function get() { - eachRecursive(global.definition) - return global.definition + return async function get() { + await resolveRefs(globalThis.definition, globalThis.definition) + return globalThis.definition } -}() \ No newline at end of file +}() diff --git a/test/parser-refs.js b/test/parser-refs.js new file mode 100644 index 0000000..abbc43d --- /dev/null +++ b/test/parser-refs.js @@ -0,0 +1,252 @@ +/** Part of APIAddicts. See LICENSE fileor full copyright and licensing details. Supported by Madrid Digital and CloudAPPi **/ + +const assert = require('node:assert') +const http = require('node:http') + +const FLAT_SCHEMA_YAML = [ + 'components:', + ' schemas:', + ' Pet:', + ' type: object', + ' properties:', + ' name:', + ' type: string', +].join('\n') + +const NESTED_SCHEMA_YAML = [ + 'components:', + ' schemas:', + ' Pet:', + ' type: object', + ' properties:', + ' address:', + " $ref: '#/components/schemas/Address'", + ' Address:', + ' type: object', + ' properties:', + ' street:', + ' type: string', +].join('\n') + +const PET_SCHEMA = { type: 'object', properties: { name: { type: 'string' } } } + +const PET_WITH_ADDRESS = { + type: 'object', + properties: { + address: { type: 'object', properties: { street: { type: 'string' } } } + } +} + +function startServer(responseBody) { + return new Promise(resolve => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/yaml' }) + res.end(responseBody) + }) + server.listen(0, '127.0.0.1', () => resolve(server)) + }) +} + +describe('parser-refs', () => { + beforeEach(() => { + delete require.cache[require.resolve('../src/parser/openapi3/refs.js')] + delete require.cache[require.resolve('../src/parser/swagger2/refs.js')] + }) + + it('resolves external http $ref with fragment (openapi3)', async () => { + const server = await startServer(FLAT_SCHEMA_YAML) + const { port } = server.address() + + globalThis.definition = { + paths: { + '/pets': { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { '$ref': `http://127.0.0.1:${port}/schema.yaml#/components/schemas/Pet` } + } + } + } + } + } + } + } + } + + const refs = require('../src/parser/openapi3/refs.js') + await refs() + server.close() + + const schema = globalThis.definition.paths['/pets'].get.responses['200'].content['application/json'].schema + assert.deepStrictEqual(schema, PET_SCHEMA) + }) + + it('resolves external http $ref with fragment (swagger2)', async () => { + const server = await startServer(FLAT_SCHEMA_YAML) + const { port } = server.address() + + globalThis.definition = { + paths: { + '/pets': { + get: { + responses: { + '200': { + schema: { '$ref': `http://127.0.0.1:${port}/schema.yaml#/components/schemas/Pet` } + } + } + } + } + } + } + + const refs = require('../src/parser/swagger2/refs.js') + await refs() + server.close() + + const schema = globalThis.definition.paths['/pets'].get.responses['200'].schema + assert.deepStrictEqual(schema, PET_SCHEMA) + }) + + it('resolves nested internal $refs inside external document (openapi3)', async () => { + const server = await startServer(NESTED_SCHEMA_YAML) + const { port } = server.address() + + globalThis.definition = { + paths: { + '/pets': { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { '$ref': `http://127.0.0.1:${port}/schema.yaml#/components/schemas/Pet` } + } + } + } + } + } + } + } + } + + const refs = require('../src/parser/openapi3/refs.js') + await refs() + server.close() + + const schema = globalThis.definition.paths['/pets'].get.responses['200'].content['application/json'].schema + assert.deepStrictEqual(schema, PET_WITH_ADDRESS) + }) + + it('resolves transitive external $refs (openapi3)', async () => { + const addressServer = await startServer([ + 'type: object', + 'properties:', + ' street:', + ' type: string', + ].join('\n')) + const { port: addressPort } = addressServer.address() + + const petServer = await startServer([ + 'components:', + ' schemas:', + ' Pet:', + ' type: object', + ' properties:', + ' address:', + ` $ref: 'http://127.0.0.1:${addressPort}/address.yaml'`, + ].join('\n')) + const { port: petPort } = petServer.address() + + globalThis.definition = { + paths: { + '/pets': { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { '$ref': `http://127.0.0.1:${petPort}/pet.yaml#/components/schemas/Pet` } + } + } + } + } + } + } + } + } + + const refs = require('../src/parser/openapi3/refs.js') + await refs() + petServer.close() + addressServer.close() + + const schema = globalThis.definition.paths['/pets'].get.responses['200'].content['application/json'].schema + assert.deepStrictEqual(schema, PET_WITH_ADDRESS) + }) + + it('emits clear error for HTTP error response on external $ref (openapi3)', async () => { + const server = http.createServer((req, res) => { + res.writeHead(404) + res.end('Not Found') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() + + globalThis.definition = { + paths: { + '/pets': { + get: { responses: { '200': { schema: { '$ref': `http://127.0.0.1:${port}/schema.yaml` } } } } + } + } + } + + const originalExit = process.exit + const originalError = console.error + let exitCode + process.exit = (code) => { exitCode = code; throw new Error('process.exit:' + code) } + console.error = () => {} + + const refs = require('../src/parser/openapi3/refs.js') + try { + await refs() + assert.fail('Expected process.exit to be called') + } catch (e) { + if (exitCode === undefined) throw e + assert.strictEqual(exitCode, 1) + } finally { + process.exit = originalExit + console.error = originalError + server.close() + } + }) + + it('emits clear error for unreachable external $ref (openapi3)', async () => { + globalThis.definition = { + paths: { + '/pets': { + get: { responses: { '200': { schema: { '$ref': 'http://127.0.0.1:1/schema.yaml' } } } } + } + } + } + + const originalExit = process.exit + const originalError = console.error + let exitCode + process.exit = (code) => { exitCode = code; throw new Error('process.exit:' + code) } + console.error = () => {} + + const refs = require('../src/parser/openapi3/refs.js') + try { + await refs() + assert.fail('Expected process.exit to be called') + } catch (e) { + if (exitCode === undefined) throw e + assert.strictEqual(exitCode, 1) + } finally { + process.exit = originalExit + console.error = originalError + } + }) +})