From c2b19ca063d059e4959b1374b60ec65b3a9936ab Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Thu, 25 Jun 2026 19:02:54 -0500 Subject: [PATCH 1/3] feat: resolve external $ref URLs with full nested and transitive support --- index.js | 10 +- seeds/parserInitialGoodOpenApi3.json | 2 +- src/parser/openapi3/refs.js | 111 +++++++++++--- src/parser/swagger2/refs.js | 108 ++++++++++---- test/parser-refs.js | 215 +++++++++++++++++++++++++++ 5 files changed, 395 insertions(+), 51 deletions(-) create mode 100644 test/parser-refs.js diff --git a/index.js b/index.js index a378f2a..2839796 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')() @@ -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/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..f4be0d1 100644 --- a/src/parser/openapi3/refs.js +++ b/src/parser/openapi3/refs.js @@ -2,21 +2,90 @@ '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] - } +const https = require('node:https') +const http = require('node:http') +const yaml = require('js-yaml') +const error = require('../../utils/error') + +const externalDocCache = new Map() + +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) } - let property = obj[k] - property = property.replace('#/', '') - let propertiesArray = property.split('/') - let refObject = findObject(globalThis.definition, propertiesArray) + let data = '' + res.on('data', chunk => data += chunk) + res.on('end', () => { + try { resolve(yaml.load(data)) } + catch (e) { reject(new Error('Failed to parse external ref from ' + url + ': ' + e.message)) } + }) + }).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) : '' + } +} + +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 +} + +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 +} + +function isExternalRef(refValue) { + return refValue.startsWith('http://') || refValue.startsWith('https://') +} + +function collectSiblings(obj) { + const siblings = {} + for (const sib of Object.keys(obj)) { + if (sib !== '$ref') siblings[sib] = obj[sib] + } + return siblings +} + +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 = collectSiblings(obj) + const refObject = isExternalRef(obj[k]) + ? await fetchAndResolveExternal(obj[k], seenUrls) + : resolveInternalRef(obj[k], localDefinition) delete obj[k] Object.assign(obj, refObject, siblings) } @@ -24,11 +93,9 @@ function eachRecursive(obj) { } function findObject(obj, propertiesArray) { - if(propertiesArray.length < 1) { - return obj - } - - let property = propertiesArray.shift() + if (propertiesArray.length < 1) return obj + if (obj === undefined || obj === null) return undefined + const property = propertiesArray.shift() return findObject(obj[property], propertiesArray) } @@ -46,9 +113,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/swagger2/refs.js b/src/parser/swagger2/refs.js index 6654c80..faaec50 100644 --- a/src/parser/swagger2/refs.js +++ b/src/parser/swagger2/refs.js @@ -2,39 +2,97 @@ '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) - - // Clear ref property - delete obj[k] - - // Assign properties refOcject - Object.assign(obj, refObject) +const https = require('node:https') +const http = require('node:http') +const yaml = require('js-yaml') +const error = require('../../utils/error') + +const externalDocCache = new Map() + +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) } - } + let data = '' + res.on('data', chunk => data += chunk) + res.on('end', () => { + try { resolve(yaml.load(data)) } + catch (e) { reject(new Error('Failed to parse external ref from ' + url + ': ' + e.message)) } + }) + }).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 +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 +} + +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 +} + +function isExternalRef(refValue) { + return refValue.startsWith('http://') || refValue.startsWith('https://') +} + +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 refObject = isExternalRef(obj[k]) + ? await fetchAndResolveExternal(obj[k], seenUrls) + : resolveInternalRef(obj[k], localDefinition) + delete obj[k] + Object.assign(obj, refObject) + } } +} - let property = propertiesArray.shift() +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) } 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..0009c28 --- /dev/null +++ b/test/parser-refs.js @@ -0,0 +1,215 @@ +/** 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 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) { + assert.strictEqual(exitCode, 1) + } finally { + process.exit = originalExit + console.error = originalError + } + }) +}) From 281405be6b8dfb1cf794e646af272d3bdb507dca Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Fri, 26 Jun 2026 08:45:07 -0500 Subject: [PATCH 2/3] update changelog and version --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- src/parser/openapi3/refs.js | 4 ++++ src/parser/swagger2/refs.js | 4 ++++ test/parser-refs.js | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 53 insertions(+), 3 deletions(-) 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/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/src/parser/openapi3/refs.js b/src/parser/openapi3/refs.js index f4be0d1..f8f0719 100644 --- a/src/parser/openapi3/refs.js +++ b/src/parser/openapi3/refs.js @@ -16,6 +16,10 @@ function fetchUrl(url) { 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', () => { diff --git a/src/parser/swagger2/refs.js b/src/parser/swagger2/refs.js index faaec50..ad7b4ba 100644 --- a/src/parser/swagger2/refs.js +++ b/src/parser/swagger2/refs.js @@ -16,6 +16,10 @@ function fetchUrl(url) { 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', () => { diff --git a/test/parser-refs.js b/test/parser-refs.js index 0009c28..93069b2 100644 --- a/test/parser-refs.js +++ b/test/parser-refs.js @@ -186,6 +186,41 @@ describe('parser-refs', () => { 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) { + 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: { From bb8970028b8af105a15f34ea14bbe86a6addcb42 Mon Sep 17 00:00:00 2001 From: Melsy Huamani Date: Fri, 26 Jun 2026 08:55:19 -0500 Subject: [PATCH 3/3] fix: sonar duplicated code and sonar issues --- index.js | 4 +- src/parser/openapi3/refs.js | 93 +------------------------------- src/parser/refs.js | 103 ++++++++++++++++++++++++++++++++++++ src/parser/swagger2/refs.js | 92 +------------------------------- test/parser-refs.js | 2 + 5 files changed, 111 insertions(+), 183 deletions(-) create mode 100644 src/parser/refs.js diff --git a/index.js b/index.js index 2839796..203cf6c 100644 --- a/index.js +++ b/index.js @@ -232,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) } diff --git a/src/parser/openapi3/refs.js b/src/parser/openapi3/refs.js index f8f0719..6daa70e 100644 --- a/src/parser/openapi3/refs.js +++ b/src/parser/openapi3/refs.js @@ -2,76 +2,7 @@ 'use strict' -const https = require('node:https') -const http = require('node:http') -const yaml = require('js-yaml') -const error = require('../../utils/error') - -const externalDocCache = new Map() - -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', () => { - try { resolve(yaml.load(data)) } - catch (e) { reject(new Error('Failed to parse external ref from ' + url + ': ' + e.message)) } - }) - }).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) : '' - } -} - -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 -} - -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 -} - -function isExternalRef(refValue) { - return refValue.startsWith('http://') || refValue.startsWith('https://') -} +const createRefsModule = require('../refs') function collectSiblings(obj) { const siblings = {} @@ -81,27 +12,7 @@ function collectSiblings(obj) { return siblings } -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 = collectSiblings(obj) - const refObject = isExternalRef(obj[k]) - ? await fetchAndResolveExternal(obj[k], seenUrls) - : resolveInternalRef(obj[k], localDefinition) - delete obj[k] - Object.assign(obj, refObject, siblings) - } - } -} - -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) -} +const resolveRefs = createRefsModule({ prepareSiblings: collectSiblings }) function liftAdditionalOperations() { if (!globalThis.definition.paths) return 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 ad7b4ba..d9ed935 100644 --- a/src/parser/swagger2/refs.js +++ b/src/parser/swagger2/refs.js @@ -2,97 +2,9 @@ 'use strict' -const https = require('node:https') -const http = require('node:http') -const yaml = require('js-yaml') -const error = require('../../utils/error') +const createRefsModule = require('../refs') -const externalDocCache = new Map() - -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', () => { - try { resolve(yaml.load(data)) } - catch (e) { reject(new Error('Failed to parse external ref from ' + url + ': ' + e.message)) } - }) - }).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) : '' - } -} - -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 -} - -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 -} - -function isExternalRef(refValue) { - return refValue.startsWith('http://') || refValue.startsWith('https://') -} - -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 refObject = isExternalRef(obj[k]) - ? await fetchAndResolveExternal(obj[k], seenUrls) - : resolveInternalRef(obj[k], localDefinition) - delete obj[k] - Object.assign(obj, refObject) - } - } -} - -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) -} +const resolveRefs = createRefsModule() module.exports = function() { return async function get() { diff --git a/test/parser-refs.js b/test/parser-refs.js index 93069b2..abbc43d 100644 --- a/test/parser-refs.js +++ b/test/parser-refs.js @@ -213,6 +213,7 @@ describe('parser-refs', () => { 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 @@ -241,6 +242,7 @@ describe('parser-refs', () => { 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