diff --git a/package.json b/package.json index 0730aac..b0e6ba3 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,10 @@ "axios": "^0.21.1", "debug": "^4.1.1", "jsonq": "^1.2.0", - "lodash": "^4.17.15", + "lodash": "^4.17.21", "snyk": "^1.360.0", "snyk-config": "^4.0.0", - "snyk-request-manager": "1.4.0", + "snyk-request-manager": "^1.4.1", "source-map-support": "^0.5.16", "tslib": "^1.10.0", "typescript": "^3.9.5", diff --git a/src/lib/client/utils/utils.ts b/src/lib/client/utils/utils.ts index c6e0a9e..a47e21b 100644 --- a/src/lib/client/utils/utils.ts +++ b/src/lib/client/utils/utils.ts @@ -22,3 +22,26 @@ export const getProjectUUID = async ( } return selectedProjectArray[0].id; }; + +export const getTotalPaginationCount = (linkHeaderLine: string): number => { + const regExp = /(\?|&)page=([0-9]+)/; + let count = 1; + let linkLastPage: string[] = linkHeaderLine + .replace('link: ', '') + .split(',') + .filter((link) => link.indexOf('rel=last') > 0); + if ( + linkLastPage && + linkLastPage.length == 1 && + linkLastPage[0].match(regExp) + ) { + const lastPageMatch = linkLastPage[0].match(regExp); + count = lastPageMatch ? parseInt(lastPageMatch[2]) : 1; + } else { + throw new Error( + `Error unable to parse extract total page count from links in request header ${linkHeaderLine}`, + ); + } + + return count; +}; diff --git a/src/lib/generators/generate.ts b/src/lib/generators/generate.ts index 9fbb8ca..36df82a 100644 --- a/src/lib/generators/generate.ts +++ b/src/lib/generators/generate.ts @@ -93,6 +93,7 @@ const generateClass = ( ${generateMethods(classToGenerate)} + ${generatePaginationMethods(classToGenerate)} ${integrateAbstractionMethods(classToGenerate)} } @@ -178,7 +179,8 @@ const generateResponseInterfaces = ( const methodsArray = classToGenerateResponseInterfacesFor.methods; methodsArray.forEach((method) => { - if (!_.isEmpty(method.response)) { + if (!_.isEmpty(method.response) && !codeToReturn.includes(`${ + utils.formatClassName(classToGenerateResponseInterfacesFor.name) +_.capitalize(method.verb) + 'ResponseType'}`)) { switch (method.response?.type) { case 'custom': // codeToReturn += `export interface ${ @@ -618,6 +620,254 @@ const generateMethods = (classToGenerateMethodsFor: ConsolidatedClass) => { return ''; } }; +const generatePaginationMethods = ( + classToGenerateMethodsFor: ConsolidatedClass, +) => { + let methodsJson = classToGenerateMethodsFor.methods; + let codeToReturn = ''; + + let methodsMap: Map = new Map(); + if (methodsJson) { + // @ts-ignore + methodsJson.forEach((method) => { + let argsList: Array = []; + + if (!_.isEmpty(method.body)) { + argsList.push( + `body: ${ + utils.formatClassName(classToGenerateMethodsFor.name) + + _.capitalize(method.verb) + + 'BodyType' + }`, + ); + } + + let paramsCode: Array = []; + // @ts-ignore + method.params.forEach((param) => { + const required = !param.required ? '?' : ''; + paramsCode.push(`${param.name}${required}: ${param.type}`); + }); + //let qsParamsCode: Array = []; + // @ts-ignore + method.qsParams.forEach((qsParam) => { + const required = !qsParam.required ? '?' : ''; + //qsParamsCode.push(`${qsParam.name}${required}: ${qsParam.type}`); + argsList.push(`${qsParam.name}${required}: ${qsParam.type}`); + }); + + let urlForPreparedMethod = `\`${method.url + .toString() + .replace(/{/g, "${Object(this.currentContext)['") + .replace(/}/g, "']}")}\``; + + const currentMethod: PreparedMethod = { + name: method.verb, + paramList: paramsCode, + argsList: argsList, + url: urlForPreparedMethod, + response: method.response, + }; + if (methodsMap.has(method.verb)) { + let paramList = _.uniq(method.params.concat(method.qsParams)); + let url = method.url; + + let existingMethodParamList = methodsMap.get(method.verb)!.argsList; + let existingUrl = methodsMap.get(method.verb)?.url; + + let finalMethodParamList: string[] = []; + if (existingMethodParamList.length == 0 && paramList.length == 0) { + // Just passing through if no parameters but only different values in the path + // so far only /user/{usedId}, with userId=me being a special case called out + // in other words, userId being 'me' or another value if handled in the class constructor + // so no need to tweak the url + url = `${existingUrl}`; + } else if (existingMethodParamList.length > paramList.length) { + finalMethodParamList = existingMethodParamList; + const paramListDifference = _.difference( + existingMethodParamList, + paramList.map((x) => `${x.name}?: ${x.type}`), + ); + url = `if(${paramListDifference + .map((param) => { + let processedParam = param.split('?')[0].split(':')[0]; + processedParam = + "`${Object(this.currentContext)['" + + processedParam + + "']}` != ''"; + return processedParam; + }) + .join(' && ')}){ + url = \`${url + .toString() + .replace(/{/g, "${Object(this.currentContext)['") + .replace(/}/g, "']}")}\` + } else { + url = ${existingUrl} + }`; + } else { + finalMethodParamList = paramList.map((x) => `${x.name}?: ${x.type}`); + const paramListDifference = _.difference( + paramList.map((x) => `${x.name}?: ${x.type}`), + existingMethodParamList, + ); + url = `if(${paramListDifference + .map((param) => { + let processedParam = param.split('?')[0].split(':')[0]; + processedParam = + "`${Object(this.currentContext)['" + + processedParam + + "']}` != ''"; + return processedParam; + }) + .join(' && ')}){ + url = \`${url + .toString() + .replace(/{/g, "${Object(this.currentContext)['") + .replace(/}/g, "']}")}\` + } else { + url = ${existingUrl} + }`; + } + const updatedMethod: PreparedMethod = { + name: method.verb, + argsList: currentMethod.argsList, + url: url, + response: currentMethod.response, + }; + methodsMap.set(method.verb, updatedMethod); + } else { + const url = `${currentMethod.url}`; + const updatedMethod: PreparedMethod = { + name: method.verb, + paramList: currentMethod.paramList, + argsList: currentMethod.argsList, + url: url, + response: currentMethod.response, + }; + methodsMap.set(method.verb, updatedMethod); + } + }); + + methodsMap.forEach((method) => { + let argsList: Array = []; + let qsParametersNamesList: Array = []; + + if (method.argsList.some((x) => x.startsWith('page'))) { + if (method.argsList.some((x) => x.startsWith('body:'))) { + argsList.push('body: string'); + } + method.argsList + .filter((x) => !x.startsWith('body:')) + .filter((x) => !x.startsWith('perPage')) + .filter((x) => !x.startsWith('page')) + .forEach((qsParameterName) => { + qsParametersNamesList.push( + qsParameterName.split(':')[0].replace('?', ''), + ); + }); + method.argsList = method.argsList + .filter((x) => !x.startsWith('perPage')) + .filter((x) => !x.startsWith('page')); + method.argsList.unshift('noLimitMode: boolean = false'); + + let qsIfStatements = ''; + qsParametersNamesList.forEach((qsParameterName) => { + qsIfStatements += ` + if(${qsParameterName}){ + urlQueryParams.push('${qsParameterName}='+${qsParameterName}) + }\n`; + }); + + let emptyBodyNeeded = false; + if ( + (method.name == 'post' || method.name == 'put') && + !method.argsList.map((x) => x.split(':')[0]).includes('body') + ) { + emptyBodyNeeded = true; + } + + codeToReturn += ` + async ${method.name}All (${method.argsList}):${ + method.response?.type == 'bodyless' + ? 'Promise' + : 'Promise<' + + utils.formatClassName(classToGenerateMethodsFor.name) + + utils.formatClassName(method.name) + + 'ResponseType[]>' + } { + let url = '' + let urlQueryParams: Array = [] + ${method.url.startsWith('if') ? '' : 'url = '}${method.url} + ${qsIfStatements} + + let currentPage = 1; + const PAGELIMIT = 100 + urlQueryParams.push('perPage=' + PAGELIMIT); + + if(urlQueryParams.length > 0){ + url += \`?\${urlQueryParams.join("&")}\` + } + + + + const fullResponseUserSetting = Object(this.currentContext)['fullResponse'] + Object(this.currentContext)['fullResponse'] = true + + try { + const firstPageResult = await requestManager.request({verb: '${ + method.name + }', url: url ${ + method.argsList.map((x) => x.split(':')[0]).includes('body') + ? ',body : JSON.stringify(body)' + : `${emptyBodyNeeded ? ', body: JSON.stringify({})' : ''}` + }}) + Object(this.currentContext)['fullResponse'] = fullResponseUserSetting + let totalPages = 1 + if(firstPageResult.headers.link){ + totalPages = utils.getTotalPaginationCount(firstPageResult.headers.link) + } + + + const bulkRequestArray = [] + for(let i=1; i x.split(':')[0]).includes('body') + ? ',body : JSON.stringify(body)' + : `${emptyBodyNeeded ? ', body: JSON.stringify({})' : ''}` + }}) + if(!noLimitMode && currentPage > PAGELIMIT){ + break; + } + } + + let bulkResultsSet: Object[] = [] + if(bulkRequestArray.length>0){ + bulkResultsSet = await requestManager.requestBulk(bulkRequestArray) + } + + const resultsSet = [firstPageResult.data, ...bulkResultsSet.map(x=> Object(x)['data'])] + + + return resultsSet + + } catch (err) { + throw new ClientError(err) + } + + } + `; + } + }); + + return codeToReturn; + } else { + return ''; + } +}; const generateImportsForAbstractionMethods = ( classToIntegrateAbstractionMethodsIn: ConsolidatedClass, @@ -736,6 +986,7 @@ const parsedJSON = JSON.parse(preparedJson) as Array; const initLines = ` import { ClientError } from '../errors/clientError' import { requestsManager } from 'snyk-request-manager' +import * as utils from '../utils/utils' const requestManager = new requestsManager(${requestManagerSettings}) diff --git a/src/lib/generators/generateTestCases.ts b/src/lib/generators/generateTestCases.ts index 1931936..6163c47 100644 --- a/src/lib/generators/generateTestCases.ts +++ b/src/lib/generators/generateTestCases.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as _ from 'lodash'; +import { type } from 'os'; import { ConsolidatedClass } from './generate'; export const generateTestCases = async (preparedJsonPath: string) => { @@ -165,7 +166,10 @@ const extractBodyTypeFromCommand = (command: string): string => { return namespacePath != '' ? `${namespacePath}.${typeName}` : `${typeName}`; }; -const extractResponseTypeFromCommand = (command: string): string => { +const extractResponseTypeFromCommand = ( + command: string, + isPaginationMethod: boolean = false, +): string => { const splitCommand = command.split('.'); // length-1 of that array gives the number of subclasses we need to go through if (splitCommand.length > 2) { @@ -178,13 +182,14 @@ const extractResponseTypeFromCommand = (command: string): string => { const namespacePath = readyToComposeCommandHierarchy .slice(0, readyToComposeCommandHierarchy.length - 2) .join('.'); - const typeName = + let typeName = readyToComposeCommandHierarchy .slice( readyToComposeCommandHierarchy.length - 2, readyToComposeCommandHierarchy.length, ) .join('') + 'ResponseType'; + typeName = isPaginationMethod ? typeName.replace('all', '') : typeName; return namespacePath != '' ? `${namespacePath}.${typeName}` : `${typeName}`; }; @@ -198,13 +203,18 @@ const generateTestFile = ( className, )}Types} from '../../src/lib/index' `; + // codeToReturn += `import { AxiosResponse } from "axios" + // import mockAxios from 'jest-mock-axios' + // afterEach(() => { + // mockAxios.reset(); + // }); + // `; codeToReturn += `import { AxiosResponse } from "axios" - import mockAxios from 'jest-mock-axios' - afterEach(() => { - mockAxios.reset(); - }); + import nock from 'nock' + jest.unmock('axios'); `; const namespacesToImport: Array = []; + commandList.forEach((command) => { if (command[0].indexOf('.put(') > 0 || command[0].indexOf('.post(') > 0) { const namespaceName = extractBodyTypeFromCommand(command[0]) @@ -214,7 +224,17 @@ const generateTestFile = ( namespacesToImport.push(namespaceName); } } + if (command[0].indexOf('page') > 0) { + let commandPagination = [...command]; + commandPagination[0] = commandPagination[0] + .replace('.post(', '.postAll(') + .replace('.put(', '.putAll(') + .replace('.get(', '.getAll(') + .replace('.delete(', '.deleteAll('); + commandList.push(commandPagination); + } }); + commandList.forEach((command) => { const namespaceName = extractResponseTypeFromCommand(command[0]) .split('.') @@ -258,23 +278,34 @@ const generateTestFile = ( codeToReturn += `describe('Testing ${_.capitalize(className)} class', () => { `; + commandList.forEach((command) => { - const commandMethod: string = command[0] + let commandMethod: string = command[0] .split('.') [command[0].split('.').length - 1].split('(')[0]; - const commandMethodArguments: Array = command[0] + let commandMethodArguments: Array = command[0] .split('.') [command[0].split('.').length - 1].split('(')[1] .split(')')[0] .split(','); - const commandCoordinates: Array = command[0] + let commandCoordinates: Array = command[0] .split('.') .map((x) => x.split('(')[0]); commandCoordinates.shift(); + const isPaginationMethod = commandMethod.toLowerCase().endsWith('all'); + if (isPaginationMethod) { + commandMethod = commandMethod.replace('All', ''); + commandMethodArguments = commandMethodArguments + .filter((x) => x != 'page') + .filter((x) => x != 'perPage'); + } + codeToReturn += ` it('Testing endpoint: ${ command[1] - } - ${commandMethod.toUpperCase()} method', async () => { + } - ${commandMethod.toUpperCase()}${ + isPaginationMethod ? 'ALL' : '' + } method', async () => { try { `; @@ -284,7 +315,8 @@ const generateTestFile = ( className, )}Types.${extractResponseTypeFromCommand( command[0], - )} = fixtures.response.${commandCoordinates.join('.')} + isPaginationMethod, + )} = JSON.parse(fixtures.response.${commandCoordinates.join('.')}) const axiosResponse: AxiosResponse = { @@ -294,6 +326,37 @@ const generateTestFile = ( config: {}, headers: {} }; + + ${ + isPaginationMethod + ? `const responseArray:${_.capitalize( + className, + )}Types.${extractResponseTypeFromCommand( + command[0], + isPaginationMethod, + )}[] = [];responseArray.push(response)` + : '' + } + + nock('https://snyk.io') + .persist() + .post(/.*/) + .reply(200, () => { + return response; + }) + .put(/.*/) + .reply(200, () => { + return response; + }) + .delete(/.*/) + .reply(200, () => { + return response; + }) + .get(/.*/) + .reply(200, () => { + return response; + }); + `; // if (commandMethod == 'put' || commandMethod == 'post') { @@ -324,7 +387,21 @@ const generateTestFile = ( .filter((x) => x == 'body') .map((x) => `fixtures.request.${commandCoordinates.join('.')}.` + x); - codeToReturn += `const promise = new ${command[0] + const currentCommand = isPaginationMethod + ? command[0] + .replace('page', '') + .replace('(,)', '()') + .replace('(,', '(') + .replace(',)', ')') + .replace(',,', ',') + .replace('perPage', '') + .replace('(,)', '()') + .replace('(,', '(') + .replace(',)', ')') + .replace(',,', ',') + : command[0]; + console.log(currentCommand); + codeToReturn += `const result = await new ${currentCommand .replace(/:([a-zA-Z0-9]+)/g, `:fixtures.$1`) .replace( `${ @@ -337,29 +414,34 @@ const generateTestFile = ( .join(','), )} - `; - codeToReturn += ` - mockAxios.mockResponseFor({url: \`${url}\`},axiosResponse); - - expect(mockAxios.${commandMethod}).toHaveBeenCalledWith(\`${url}\`${ - body.join() == '' - ? `${ - commandMethod == 'post' || commandMethod == 'put' - ? ',JSON.stringify({})' - : '' - }` - : ', JSON.stringify(' + body.join() + ')' - }) - - - const result:${_.capitalize( - className, - )}Types.${extractResponseTypeFromCommand(command[0])} = await promise - expect(result).toEqual( - response, - ); + ${isPaginationMethod ? 'responseArray' : 'response'}, + ); `; + // codeToReturn += ` + // mockAxios.mockResponseFor({url: \`${url}\`},axiosResponse); + + // expect(mockAxios.${commandMethod}).toHaveBeenCalledWith(\`${url}\`${ + // body.join() == '' + // ? `${ + // commandMethod == 'post' || commandMethod == 'put' + // ? ',JSON.stringify({})' + // : '' + // }` + // : ', JSON.stringify(' + body.join() + ')' + // }) + + // const result:${_.capitalize( + // className, + // )}Types.${extractResponseTypeFromCommand( + // command[0], + // isPaginationMethod, + // )}${isPaginationMethod ? '[]' : ''} = await promise + + // expect(result).toEqual( + // response, + // ); + // `; // ${ // commandMethod.toUpperCase() == 'GET' || // commandMethod.toUpperCase() == 'DELETE' diff --git a/src/lib/utils/utils.ts b/src/lib/utils/utils.ts index 97cf9f8..81c4055 100644 --- a/src/lib/utils/utils.ts +++ b/src/lib/utils/utils.ts @@ -98,104 +98,3 @@ export function tsPartial(type: string): string { export function tsUnionOf(types: string[]): string { return `${types.join(' | ')}`; } - -// export function comment(text: string): string { -// return `/** -// * ${text.trim().replace('\n+$', '').replace(/\n/g, '\n * ')} -// */ -// `; -// } - -// /** shim for Object.fromEntries() for Node < 13 */ -// export function fromEntries(entries: [string, any][]): object { -// return entries.reduce((obj, [key, val]) => ({ ...obj, [key]: val }), {}); -// } - -// /** Return type of node (works for v2 or v3, as there are no conflicting types) */ -// type SchemaObjectType = -// | 'anyOf' -// | 'array' -// | 'boolean' -// | 'enum' -// | 'number' -// | 'object' -// | 'oneOf' -// | 'ref' -// | 'string'; -// export function nodeType(obj: any): SchemaObjectType | undefined { -// if (!obj || typeof obj !== 'object') { -// return undefined; -// } - -// if (obj['$ref']) { -// return 'ref'; -// } - -// // enum -// if (Array.isArray(obj.enum)) { -// return 'enum'; -// } - -// // boolean -// if (obj.type === 'boolean') { -// return 'boolean'; -// } - -// // string -// if ( -// ['binary', 'byte', 'date', 'dateTime', 'password', 'string'].includes( -// obj.type, -// ) -// ) { -// return 'string'; -// } - -// // number -// if (['double', 'float', 'integer', 'number'].includes(obj.type)) { -// return 'number'; -// } - -// // anyOf -// if (Array.isArray(obj.anyOf)) { -// return 'anyOf'; -// } - -// // oneOf -// if (Array.isArray(obj.oneOf)) { -// return 'oneOf'; -// } - -// // array -// if (obj.type === 'array' || obj.items) { -// return 'array'; -// } - -// // return object by default -// return 'object'; -// } - -// /** Convert $ref to TS ref */ -// export function transformRef(ref: string): string { -// const parts = ref.replace(/^#\//, '').split('/'); -// return `${parts[0]}["${parts.slice(1).join('"]["')}"]`; -// } - -// /** Convert T into T[]; */ -// export function tsArrayOf(type: string): string { -// return `(${type})[]`; -// } - -// /** Convert T, U into T & U; */ -// export function tsIntersectionOf(types: string[]): string { -// return `(${types.join(') & (')})`; -// } - -// /** Convert T into Partial */ -// export function tsPartial(type: string): string { -// return `Partial<${type}>`; -// } - -// /** Convert [X, Y, Z] into X | Y | Z */ -// export function tsUnionOf(types: string[]): string { -// return `(${types.join(') | (')})`; -// } diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index 43cf376..4b22dfb 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -28,4 +28,11 @@ describe('Testing utils', () => { expect(result).toEqual('ab9e037f-9020-4f77-9c48-b1cb0295a4b6'); }); + + it('Testing getTotalPaginationCount', async () => { + const headerLinkLine = + 'link: ; rel=last, ; rel=next'; + const lastPageCount = utils.getTotalPaginationCount(headerLinkLine); + expect(lastPageCount).toEqual(4125); + }); });