diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d100684..ce5a484 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,12 +1,12 @@ name: Automated deploy on: - push: - tags: - - 'v*' + release: + types: + - created jobs: - release: + deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 diff --git a/__tests__/oas2har.spec.js b/__tests__/oas2har.spec.js index f25ce56..25142d6 100644 --- a/__tests__/oas2har.spec.js +++ b/__tests__/oas2har.spec.js @@ -11,7 +11,7 @@ test('GitHub swagger v2 JSON to HAR', async () => { }); test('Petstore OpenApi v3 YAML to JSON converts to HAR', async () => { - const [firstRequest] = await oasToHarList(process.cwd() + '/__tests__/fixtures/petstore_oas.yaml'); + const [firstRequest, ...rest] = await oasToHarList(process.cwd() + '/__tests__/fixtures/petstore_oas.yaml'); const { har } = firstRequest; expect(har.method).toEqual('PUT'); diff --git a/__tests__/params-serialization.spec.js b/__tests__/params-serialization.spec.js new file mode 100644 index 0000000..c803335 --- /dev/null +++ b/__tests__/params-serialization.spec.js @@ -0,0 +1,101 @@ +const { paramsSerialization } = require('../src/converter') +const { toFlattenArray } = require('../src/utils') + +describe('Query Parameters', () => { + const array = [1, 2, 3, 4, 5] + const object = { role: 'admin', name: { first: 'Jon', last: 'Snow' }, filter: { gt: 1, lte: 10 } } + const primitive = 1 + const name = 'id' + + test('should serialize primitive with default method', () => { + const result = paramsSerialization(name, primitive) + expect(result.queryString).toEqual('id=1') + expect(result.values).toEqual([{ name, value: primitive + '' }]) + }) + + test('should serialize array with default method', () => { + const result = paramsSerialization(name, array) + expect(result.queryString).toEqual(`${array.map((x) => `${name}=${x}`).join('&')}`) + expect(result.values).toEqual(array.map((x) => ({ name, value: x + '' }))) + }) + + test('should serialize object with default method', () => { + const result = paramsSerialization(name, object) + expect(result.queryString).toEqual( + 'role=admin&name[first]=Jon&name[last]=Snow&filter[gt]=1&filter[lte]=10' + ) + expect(result.values).toEqual([ + { name: 'role', value: 'admin' }, + { name: 'name[first]', value: 'Jon' }, + { name: 'name[last]', value: 'Snow' }, + { name: 'filter[gt]', value: '1' }, + { name: 'filter[lte]', value: '10' }, + ]) + }) + + test('should serialize primitive with style: form and explode: false', () => { + const result = paramsSerialization(name, primitive, { explode: false }) + expect(result.queryString).toEqual('id=1') + expect(result.values).toEqual([{ name, value: primitive + '' }]) + }) + + test('should serialize array with style: form and explode: false', () => { + const result = paramsSerialization(name, array, { explode: false }) + expect(result.queryString).toEqual(`${name}=${array.join(',')}`) + expect(result.values).toEqual([{ name, value: array.join(',') }]) + }) + + test('should serialize object with style: form and explode: false', () => { + const result = paramsSerialization(name, object, { explode: false }) + expect(result.queryString).toEqual(`${name}=${toFlattenArray(object).join(',')}`) + expect(result.values).toEqual([{ name, value: toFlattenArray(object).join(',') }]) + }) + + test('should ignore an object with style: spaceDelimited', () => { + const result = paramsSerialization(name, object, { explode: true, style: 'pipeDelimited' }) + expect(result.queryString).toEqual('') + }) + + test('should ignore an object with style: pipeDelimited', () => { + const result = paramsSerialization(name, object, { explode: true, style: 'pipeDelimited' }) + expect(result.queryString).toEqual('') + }) + + test('should serialize array with style: spaceDelimited and explode: true', () => { + const result = paramsSerialization(name, array, { explode: true, style: 'spaceDelimited' }) + expect(result.queryString).toEqual(`${array.map((x) => `${name}=${x}`).join('&')}`) + expect(result.values).toEqual(array.map((x) => ({ name, value: x + '' }))) + }) + + test('should serialize array with style: spaceDelimited and explode: false', () => { + const result = paramsSerialization(name, array, { explode: false, style: 'spaceDelimited' }) + expect(result.queryString).toEqual(`${name}=${array.join('%20')}`) + expect(result.values).toEqual([{ name, value: array.join('%20') }]) + }) + + test('should serialize array with style: pipeDelimited and explode: true', () => { + const result = paramsSerialization(name, array, { explode: true, style: 'pipeDelimited' }) + expect(result.queryString).toEqual(`${array.map((x) => `${name}=${x}`).join('&')}`) + expect(result.values).toEqual(array.map((x) => ({name, value: x + ''}))) + }) + + test('should serialize array with style: pipeDelimited and explode: false', () => { + const result = paramsSerialization(name, array, { explode: false, style: 'pipeDelimited' }) + expect(result.queryString).toEqual(`${name}=${array.join('|')}`) + expect(result.values).toEqual([{ name, value: array.join('|') }]) + }) + + test('should serialize object with style: deepObject', () => { + const result = paramsSerialization(name, object, { style: 'deepObject' }) + expect(result.queryString).toEqual( + 'role=admin&name[first]=Jon&name[last]=Snow&filter[gt]=1&filter[lte]=10' + ) + expect(result.values).toEqual([ + { name: 'role', value: 'admin' }, + { name: 'name[first]', value: 'Jon' }, + { name: 'name[last]', value: 'Snow' }, + { name: 'filter[gt]', value: '1' }, + { name: 'filter[lte]', value: '10' }, + ]) + }) +}) diff --git a/package-lock.json b/package-lock.json index b01654e..a5910a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11177,10 +11177,9 @@ "dev": true }, "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", + "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==" }, "quick-lru": { "version": "1.1.0", @@ -11361,6 +11360,12 @@ "uuid": "^3.3.2" }, "dependencies": { + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", diff --git a/package.json b/package.json index 90435c0..80e8dd9 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "@types/har-format": "^1.2.4", "@types/swagger-schema-official": "^2.0.20", "js-yaml": "^3.13.1", - "url-template": "^2.0.8", - "jstoxml": "^1.6.5" + "jstoxml": "^1.6.5", + "qs": "^6.9.3", + "url-template": "^2.0.8" }, "typings": "./src/index.d.ts", "repository": { diff --git a/src/converter.js b/src/converter.js index a654e78..9c4aba3 100644 --- a/src/converter.js +++ b/src/converter.js @@ -25,8 +25,15 @@ const { sample } = require('@neuralegion/openapi-sampler') const load = require('./loader') const template = require('url-template') const { toXML } = require('jstoxml') -const querystring = require('querystring') -const { resolveRef, removeTrailingSlash, removeLeadingSlash } = require('./utils') +const querystring = require('qs') +const { + resolveRef, + removeTrailingSlash, + removeLeadingSlash, + toFlattenArray, + toFlattenObject, + isObject, +} = require('./utils') const { BOUNDARY, BASE64_PATTERN } = require('./common') /** @@ -46,11 +53,17 @@ const createHar = (swagger, baseUrl, path, method, queryParamValues) => { queryParamValues = {} } + const queryString = getQueryStrings(swagger, path, method, queryParamValues) || [] + const url = + baseUrl + + serializePath(swagger, path, method) + + (queryString.length ? '?' + queryString.map((x) => `${x.name}=${x.value}`).join('&') : '') + const har = { + url: encodeURI(url), + queryString, method: method.toUpperCase(), - url: baseUrl + serializePath(swagger, path, method), headers: getHeadersArray(swagger, path, method), - queryString: getQueryStrings(swagger, path, method, queryParamValues), httpVersion: 'HTTP/1.1', cookies: [], headersSize: 0, @@ -140,8 +153,9 @@ const getPayload = (swagger, path, method) => { */ const parseUrls = (swagger) => { if (!Array.isArray(swagger.servers)) { - const basePath = (typeof swagger.basePath !== 'undefined' ? removeLeadingSlash(swagger.basePath) : '') - const host = removeTrailingSlash(swagger.host); + const basePath = + typeof swagger.basePath !== 'undefined' ? removeLeadingSlash(swagger.basePath) : '' + const host = removeTrailingSlash(swagger.host) const schemes = typeof swagger.schemes !== 'undefined' ? swagger.schemes : ['https'] return schemes.map((x) => x + '://' + removeTrailingSlash(host + '/' + basePath)) } @@ -156,7 +170,7 @@ const parseUrls = (swagger) => { * @return {string} Base URL */ const getBaseUrl = (swagger) => { - const urls = parseUrls(swagger); + const urls = parseUrls(swagger) let preferredUrls = urls.filter((x) => x.startsWith('https') || x.startsWith('wss')) @@ -170,7 +184,7 @@ const getBaseUrl = (swagger) => { }) } -exports.getBaseUrl = getBaseUrl; +exports.getBaseUrl = getBaseUrl /** * Get array of objects describing the query parameters for a path and method pair @@ -198,15 +212,27 @@ const getQueryStrings = (swagger, path, method, values) => { } if (typeof param.in !== 'undefined' && param.in.toLowerCase() === 'query') { const data = sample(param.schema || param, {}, swagger) - queryStrings.push({ - name: param.name, - value: - typeof values[param.name] === 'undefined' - ? typeof param.default === 'undefined' - ? encodeURIComponent(typeof data === 'object' ? JSON.stringify(data) : data) - : param.default + '' - : values[param.name] + '' /* adding a empty string to convert to string */, - }) + + if (typeof values[param.name] !== 'undefined') { + queryStrings.push({ + name: param.name, + value: values[param.name] + '', + }) + } else { + if (typeof param.default === 'undefined') { + queryStrings.push( + ...paramsSerialization(param.name, data, { + style: param.style === 'undefined' ? param.collectionFormat : param.style, + explode: param.explode, + }).values + ) + } else { + queryStrings.push({ + name: param.name, + value: param.default + '', + }) + } + } } } } @@ -370,7 +396,7 @@ const oasToHarList = (swagger) => { throw new Error('Document is invalid. ' + err.message) }) } -exports.oasToHarList = oasToHarList; +exports.oasToHarList = oasToHarList /** * Produces array of HAR files for given Swagger document @@ -392,17 +418,17 @@ const parseSwaggerDoc = (swagger, baseUrl) => { const har = createHar(swagger, baseUrl, path, method) harList.push({ + url, + har, method: method.toUpperCase(), - url: url, description: swagger.paths[path][method].description || 'No description available', - har: har, }) } } return harList } -exports.parseSwaggerDoc = parseSwaggerDoc; +exports.parseSwaggerDoc = parseSwaggerDoc /** * Iterate over all defined keys under encoding and apply encoding for them @@ -469,6 +495,114 @@ const serializePath = function (swagger, path, method) { return templateUrl.expand(params) } +/** + * + * @param {string} name name a name of param + * @param {any} value a param's value + * @returns {{name: *, value: string}[]} + */ +const createQueryStringEntries = (name, value) => { + let values + + if (isObject(value)) { + const flatten = toFlattenObject(value, { format: 'indices' }) + values = Object.entries(flatten).map(([name, x]) => ({ + name, + value: x + '', + })) + } else if (Array.isArray(value)) { + values = value.map((x) => ({ name, value: x + '' })) + } else { + values = [ + { + name, + value: value + '', + }, + ] + } + + return values +} + +/** + * Translates data structures or object state into a format + * that can be transmitted and reconstructed later. + * + * @param {string} name a name of param + * @param {any} value a param's value + * @param {Object} [options] an additional options, allows to specify how these parameters should be serialized + * @param {'form'|'spaceDelimited'|'ssv'|'pipes'|'multi'|'pipeDelimited'} options.style defines + * how multiple values are delimited. + * @param {boolean} options.explode specifies whether + * arrays and objects should generate separate parameters for each array item or object property. + * @returns {Object} + */ +const paramsSerialization = (name, value, options) => { + options = Object.assign({ style: 'form', explode: true }, options) + + const getDelimiter = () => { + if (options.explode) { + return '&' + } + + switch (options.style) { + case 'spaceDelimited': + case 'ssv': + return '%20' + case 'pipeDelimited': + case 'pipes': + return '|' + case 'form': + case 'multi': + return ',' + default: + return '&' + } + } + + const delimiter = getDelimiter() + + const transposeValue = (value) => { + if (options.explode) { + return value + } + + if (Array.isArray(value)) { + return value.join(delimiter) + } else if (isObject(value)) { + return toFlattenArray(value).join(delimiter) + } + + return value + } + + const ignoreValues = (value) => { + return ( + isObject(value) && ['spaceDelimited', 'pipeDelimited', 'pipes', 'ssv'].includes(options.style) + ) + } + + const transposed = transposeValue(value) + + const arrayFormat = options.explode && Array.isArray(transposed) ? 'repeat' : 'indices' + + const object = isObject(transposed) ? transposed : { [name]: transposed } + + const queryString = querystring.stringify(!ignoreValues(value) ? object : '', { + delimiter, + arrayFormat, + format: 'RFC3986', + encode: false, + addQueryPrefix: false, + }) + + return { + queryString, + values: createQueryStringEntries(name, transposed), + } +} +exports.paramsSerialization = paramsSerialization + /* * Returns the encoded value for defined content * @@ -483,7 +617,10 @@ const encodeValue = function (value, contentType, encoding) { return JSON.stringify(value) case 'application/x-www-form-urlencoded': - return querystring.stringify(value) + return querystring.stringify(value, { + format: 'RFC3986', + encode: false, + }) case 'application/xml': const xmlOptions = { @@ -529,11 +666,11 @@ const encodeValue = function (value, contentType, encoding) { case 'image/jpg': case 'image/jpeg': - return Buffer.from([0xff, 0xd8, 0xff, 0xdb], 'hex').toString('base64') + return Buffer.from([0xff, 0xd8, 0xff, 0xdb]).toString('base64') case 'image/png': case 'image/*': - return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'hex').toString('base64') + return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString('base64') default: return typeof value === 'object' ? JSON.stringify(value) : value @@ -562,5 +699,4 @@ const encodePayload = function (data, contentType, encoding) { text: encodeValue(encodedData, contentType, encoding), } } -exports.encodePayload = encodePayload; - +exports.encodePayload = encodePayload diff --git a/src/index.d.ts b/src/index.d.ts index a3f8e32..5d8b6d4 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,11 +1,11 @@ -import { Request } from "har-format" -import { Spec } from "swagger-schema-official" +import { Request } from 'har-format' +import { Spec } from 'swagger-schema-official' interface HarRequest { - readonly method: string; - readonly url: string; - readonly description: string; - readonly har: Request; + readonly method: string + readonly url: string + readonly description: string + readonly har: Request } -export function oasToHarList(spec: Spec | string): Promise; +export function oasToHarList(spec: Spec | string): Promise diff --git a/src/index.js b/src/index.js index 106be55..834efd3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ const { oasToHarList } = require('./converter') module.exports = { - oasToHarList + oasToHarList, } diff --git a/src/loader.js b/src/loader.js index 1e87768..66387a4 100644 --- a/src/loader.js +++ b/src/loader.js @@ -18,7 +18,7 @@ const read = async (path) => { throw new Error('File not found!') } - throw new Error('Cannot read file.'); + throw new Error('Cannot read file.') } } diff --git a/src/utils.js b/src/utils.js index 621f2de..228c99a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,9 +1,9 @@ /** * Returns the value referenced in the given reference string * - * @param {object} spec + * @param {Object} spec * @param {string} ref A reference string - * @return {any} + * @return {Object} */ const resolveRef = (spec, ref) => { const parts = ref.split('/') @@ -25,11 +25,117 @@ const resolveRef = (spec, ref) => { exports.resolveRef = resolveRef - -const removeTrailingSlash = (x) => x.replace(/\/$/, ''); +/** + * Removes trailing slash from provided URL + * @param x - url + * @returns {string} + */ +const removeTrailingSlash = (x) => x.replace(/\/$/, '') exports.removeTrailingSlash = removeTrailingSlash -const removeLeadingSlash = (x) => x.replace(/^\//, ''); +/** + * Removes leading slash from provided URL + * @param x - url + * @returns {string} + */ +const removeLeadingSlash = (x) => x.replace(/^\//, '') exports.removeLeadingSlash = removeLeadingSlash + +/** + * @internal + * @param val + * @returns {boolean|boolean} + */ +const isObject = (val) => typeof val === 'object' && !Array.isArray(val) + +exports.isObject = isObject + +/** + * + * @param options + * @returns {{ALLOWED_FORMATS: Set, format(*=, *): (*|string), _options: {format: string}, verify(): void, normalizeOptions(*=): {format: string}}} + */ +const flattenConfig = (options) => ({ + _options: Object.assign({ format: 'dots' }, options), + ALLOWED_FORMATS: new Set(['indices', 'dots']), + verify() { + if (!this.ALLOWED_FORMATS.has(this._options.format)) { + throw new TypeError('Invalid format') + } + }, + format(a, b) { + if (!a) { + return b + } + + switch (this._options.format) { + case 'indices': + return `${a}[${b}]` + case 'dots': + return `${a}.${b}` + } + }, +}) + +/** + * + * @param obj An object that's needed to be converted to entries + * @param options An additional options + * @param {'indices' | 'dots'} options.format An optional format can also be passed: "indices" or "dots" + * @returns {string[]} + */ +const toFlattenArray = (obj, options) => { + const config = flattenConfig(options) + + config.verify() + + const paths = (obj = {}, head = '') => { + return Object.entries(obj).reduce((product, [key, value]) => { + const fullPath = config.format(head, key) + return isObject(value) + ? product.concat(paths(value, fullPath)) + : product.concat(fullPath, value.toString()) + }, []) + } + + return paths(obj) +} + +exports.toFlattenArray = toFlattenArray + +/** + * + * @param ob An object that's needed to be converted to entries + * @param options An additional options + * @param {'indices' | 'dots'} options.format An optional format can also be passed: "indices" or "dots" + * @returns {Object} + */ +const toFlattenObject = (ob, options) => { + const config = flattenConfig(options) + + config.verify() + + const toReturn = {} + + for (const i in ob) { + if (!ob.hasOwnProperty(i)) { + continue + } + + if (typeof ob[i] == 'object' && ob[i] !== null) { + const flatObject = toFlattenObject(ob[i]) + + for (const x in flatObject) { + if (!flatObject.hasOwnProperty(x)) continue + const fullPath = config.format(i, x) + toReturn[fullPath] = flatObject[x] + } + } else { + toReturn[i] = ob[i] + } + } + return toReturn +} +exports.toFlattenObject = toFlattenObject