From d46bd1ac4640037308333950fe39f4b368499cc5 Mon Sep 17 00:00:00 2001 From: Viachaslau Tyshkavets Date: Tue, 15 Feb 2022 22:55:31 +0300 Subject: [PATCH] refactor(oas): split monolithic `DefaultConverter` relates-to #120 --- packages/oas/src/converter/BaseUrlParser.ts | 110 +++ .../oas/src/converter/DefaultConverter.ts | 847 ++---------------- .../oas/src/converter/ParamsSerializer.ts | 50 ++ packages/oas/src/converter/Sampler.ts | 43 + .../subconverters/HeadersConverter.ts | 176 ++++ .../converter/subconverters/PathConverter.ts | 111 +++ .../subconverters/PostDataConverter.ts | 231 +++++ .../subconverters/QueryStringConverter.ts | 85 ++ .../converter/subconverters/SubConverter.ts | 3 + .../src/converter/subconverters/SubPart.ts | 6 + .../oas/src/converter/subconverters/index.ts | 6 + packages/oas/src/utils/index.ts | 8 + 12 files changed, 906 insertions(+), 770 deletions(-) create mode 100644 packages/oas/src/converter/BaseUrlParser.ts create mode 100644 packages/oas/src/converter/ParamsSerializer.ts create mode 100644 packages/oas/src/converter/Sampler.ts create mode 100644 packages/oas/src/converter/subconverters/HeadersConverter.ts create mode 100644 packages/oas/src/converter/subconverters/PathConverter.ts create mode 100644 packages/oas/src/converter/subconverters/PostDataConverter.ts create mode 100644 packages/oas/src/converter/subconverters/QueryStringConverter.ts create mode 100644 packages/oas/src/converter/subconverters/SubConverter.ts create mode 100644 packages/oas/src/converter/subconverters/SubPart.ts create mode 100644 packages/oas/src/converter/subconverters/index.ts diff --git a/packages/oas/src/converter/BaseUrlParser.ts b/packages/oas/src/converter/BaseUrlParser.ts new file mode 100644 index 00000000..66d80996 --- /dev/null +++ b/packages/oas/src/converter/BaseUrlParser.ts @@ -0,0 +1,110 @@ +import { isOASV2, isOASV3 } from '../utils'; +import { ConvertError } from '../errors'; +import { Sampler } from './Sampler'; +import { + normalizeUrl, + removeLeadingSlash, + removeTrailingSlash, + OpenAPI, + OpenAPIV2, + OpenAPIV3 +} from '@har-sdk/core'; +import pointer from 'json-pointer'; +import template from 'url-template'; + +export class BaseUrlParser { + constructor(private readonly sampler: Sampler) {} + + public parse(spec: OpenAPI.Document): string { + const urls: string[] = this.parseUrls(spec); + + if (!Array.isArray(urls) || !urls.length) { + throw new ConvertError( + 'Target must be specified', + isOASV2(spec) ? '/host' : '/servers' + ); + } + + let preferredUrls: string[] = urls.filter( + (x) => x.startsWith('https') || x.startsWith('wss') + ); + + if (!preferredUrls.length) { + preferredUrls = urls; + } + + return this.sampler.sample({ + type: 'array', + examples: preferredUrls + }); + } + + public normalizeUrl(url: string, context?: { jsonPointer: string }): string { + try { + return normalizeUrl(url); + } catch (e) { + throw new ConvertError(e.message, context?.jsonPointer); + } + } + + private parseUrls(spec: OpenAPI.Document): string[] { + if (isOASV3(spec) && spec.servers?.length) { + return this.parseServers(spec); + } + + if (isOASV2(spec) && spec.host) { + return this.parseHost(spec); + } + + return []; + } + + private parseHost(spec: OpenAPIV2.Document): string[] { + const basePath = removeLeadingSlash( + typeof spec.basePath === 'string' ? spec.basePath : '' + ).trim(); + const host = removeTrailingSlash( + typeof spec.host === 'string' ? spec.host : '' + ).trim(); + + if (!host) { + throw new ConvertError('Missing mandatory `host` field', '/host'); + } + + const schemes: string[] = Array.isArray(spec.schemes) + ? spec.schemes + : ['https']; + + return schemes.map((x: string, idx: number) => + this.normalizeUrl(`${x}://${host}/${basePath}`, { + jsonPointer: pointer.compile(['schemes', idx.toString()]) + }) + ); + } + + private parseServers(spec: OpenAPIV3.Document): string[] { + return spec.servers.map((server: OpenAPIV3.ServerObject, idx: number) => { + const variables = server.variables || {}; + const params = Object.entries(variables).reduce( + (acc, [param, variable]: [string, OpenAPIV3.ServerVariableObject]) => ({ + ...acc, + [param]: this.sampler.sample(variable, { + spec, + jsonPointer: pointer.compile([ + 'servers', + idx.toString(), + 'variables', + param + ]) + }) + }), + {} + ); + const templateUrl = template.parse(server.url); + const rawUrl = templateUrl.expand(params); + const jsonPointer = pointer.compile(['servers', idx.toString()]); + + return this.normalizeUrl(rawUrl, { jsonPointer }); + }); + } +} diff --git a/packages/oas/src/converter/DefaultConverter.ts b/packages/oas/src/converter/DefaultConverter.ts index f12dc575..cacaf1ef 100644 --- a/packages/oas/src/converter/DefaultConverter.ts +++ b/packages/oas/src/converter/DefaultConverter.ts @@ -1,113 +1,87 @@ -/* eslint-disable max-depth */ +import { BaseUrlParser } from './BaseUrlParser'; import { Converter } from './Converter'; -import { Flattener, isObject } from '../utils'; -import { ConvertError } from '../errors'; +import { ParamsSerializer } from './ParamsSerializer'; +import { Sampler } from './Sampler'; +import { + SubPart, + SubConverter, + HeadersConverter, + PathConverter, + PostDataConverter, + QueryStringConverter +} from './subconverters'; import $RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'; import { Header, - normalizeUrl, OpenAPI, OpenAPIV2, OpenAPIV3, PostData, QueryString, - removeLeadingSlash, - removeTrailingSlash, Request } from '@har-sdk/core'; -import { sample, Schema } from '@har-sdk/openapi-sampler'; -import { toXML } from 'jstoxml'; -import { stringify } from 'qs'; -import template from 'url-template'; import pointer from 'json-pointer'; -interface HarRequest { - readonly method: string; - readonly url: string; - readonly description: string; - readonly har: Request; -} +type PathItemObject = OpenAPIV2.PathItemObject | OpenAPIV3.PathItemObject; export class DefaultConverter implements Converter { - private readonly JPG_IMAGE = '/9j/2w=='; - private readonly PNG_IMAGE = 'iVBORw0KGgo='; - private readonly BOUNDARY = '956888039105887155673143'; - private readonly BASE64_PATTERN = - /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/; - private readonly flattener = new Flattener(); + private readonly sampler = new Sampler(); + private readonly paramsSerializer = new ParamsSerializer(); + private readonly baseUrlConverter = new BaseUrlParser(this.sampler); + + private spec: OpenAPI.Document; + private baseUrl: string; + private subConverters: Map>; public async convert(spec: OpenAPI.Document): Promise { - const dereferenceSpec = (await new $RefParser().dereference( + this.spec = (await new $RefParser().dereference( JSON.parse(JSON.stringify(spec)) as JSONSchema, { resolve: { file: false, http: false } } )) as OpenAPI.Document; - const baseUrl = this.getBaseUrl(dereferenceSpec); - const requests: HarRequest[] = this.parseSwaggerDoc( - dereferenceSpec, - baseUrl + this.baseUrl = this.baseUrlConverter.parse(this.spec); + this.subConverters = Object.values(SubPart).reduce( + (res, type) => res.set(type, this.createConverter(type, this.spec)), + new Map>() ); - return requests.map((x: HarRequest) => x.har); + return Object.entries(this.spec.paths).flatMap( + ([path, pathMethods]: [string, PathItemObject]) => + Object.keys(pathMethods) + .filter( + (method: string) => + !method.toLowerCase().startsWith('x-swagger-router-controller') + ) + .map((method) => this.createHarEntry(path, method)) + ); } - private parseSwaggerDoc( - spec: OpenAPI.Document, - baseUrl: string - ): HarRequest[] { - const harList: HarRequest[] = []; - - for (const [path, pathMethods] of Object.entries(spec.paths)) { - const methods: [string, any][] = Object.entries(pathMethods).filter( - ([method, _payload]: [string, any]) => - !method.toLowerCase().startsWith('x-swagger-router-controller') - ); - - for (const [method] of methods) { - const url = `${removeTrailingSlash(baseUrl)}/${removeLeadingSlash( - path - )}`; - const har = this.createHar(spec, baseUrl, path, method); + private createHarEntry(path: string, method: string): Request { + const queryString = this.convertPart( + SubPart.QUERY_STRING, + path, + method + ); - harList.push({ - url, - har, - method: method.toUpperCase(), - description: - spec.paths[path][method].description || 'No description available' - }); - } - } + const rawUrl = `${this.baseUrl}${this.convertPart( + SubPart.PATH, + path, + method + )}${this.serializeQueryString(queryString)}`; - return harList; - } - - private createHar( - spec: OpenAPI.Document, - baseUrl: string, - path: string, - method: string, - queryParamValues: Record = {} - ): Request { - const queryString = - this.getQueryStrings(spec, path, method, queryParamValues) || []; - const rawUrl = `${baseUrl}${this.serializePath(spec, path, method)}${ - queryString.length - ? `?${queryString - .map((p) => Object.values(p).map((x) => encodeURIComponent(x))) - .map(([name, value]: string[]) => `${name}=${value}`) - .join('&')}` - : '' - }`; - const postData = this.getPayload(spec, path, method); + const postData = this.convertPart( + SubPart.POST_DATA, + path, + method + ); return { queryString, - url: this.normalizeUrl(rawUrl, { + url: this.baseUrlConverter.normalizeUrl(rawUrl, { jsonPointer: pointer.compile(['paths', path, method]) }), method: method.toUpperCase(), - headers: this.getHeadersArray(spec, path, method), + headers: this.convertPart(SubPart.HEADERS, path, method), httpVersion: 'HTTP/1.1', cookies: [], headersSize: 0, @@ -116,703 +90,36 @@ export class DefaultConverter implements Converter { }; } - private normalizeUrl(url: string, context?: { jsonPointer: string }) { - try { - return normalizeUrl(url); - } catch (e) { - throw new ConvertError(e.message, context?.jsonPointer); - } - } - - // eslint-disable-next-line complexity - private getPayload( - spec: OpenAPI.Document, - path: string, - method: string - ): PostData | null { - const pathObj = spec.paths[path][method]; - const tokens = ['paths', path, method]; - const params = Array.isArray(pathObj.parameters) ? pathObj.parameters : []; - - for (const param of params) { - if ( - typeof param.in === 'string' && - param.in.toLowerCase() === 'body' && - 'schema' in param - ) { - const data = this.sampleParam(param, { + private serializeQueryString(items: QueryString[]): string { + return items.length + ? `?${items + .map((p) => Object.values(p).map((x) => encodeURIComponent(x))) + .map(([name, value]: string[]) => `${name}=${value}`) + .join('&')}` + : ''; + } + + private createConverter( + type: SubPart, + spec: OpenAPI.Document + ): SubConverter { + switch (type) { + case SubPart.HEADERS: + return new HeadersConverter(spec, this.sampler); + case SubPart.PATH: + return new PathConverter(spec, this.sampler, this.paramsSerializer); + case SubPart.POST_DATA: + return new PostDataConverter(spec, this.sampler); + case SubPart.QUERY_STRING: + return new QueryStringConverter( spec, - tokens, - idx: pathObj.parameters.indexOf(param) - }); - - let consumes; - - if (pathObj.consumes?.length) { - consumes = pathObj.consumes; - } else if (this.isOASV2(spec) && spec.consumes?.length) { - consumes = spec.consumes; - } - - const paramContentType = this.sample({ - type: 'array', - examples: consumes || ['application/json'] - }); - - return this.encodePayload(data, paramContentType); - } - } - - const content = pathObj.requestBody?.content ?? {}; - const keys = Object.keys(content); - - if (!keys.length) { - return null; - } - - const contentType = this.sample({ - type: 'array', - examples: keys - }); - const sampleContent = content[contentType]; - - if (sampleContent?.schema) { - const data = this.sample(sampleContent.schema, { - spec, - jsonPointer: pointer.compile([ - ...tokens, - 'requestBody', - 'content', - contentType, - 'schema' - ]) - }); - - return this.encodePayload(data, contentType, sampleContent.encoding); - } - - return null; - } - - private encodePayload( - data: any, - contentType: string, - encoding?: any - ): { mimeType: string; text: string } { - let encodedData = data; - - if (encoding) { - encodedData = this.encodeProperties( - Object.keys(encoding), - data, - encoding - ); - } - - return { - mimeType: contentType.includes('multipart') - ? `${contentType}; boundary=${this.BOUNDARY}` - : contentType, - text: this.encodeValue(encodedData, contentType, encoding) - }; - } - - // eslint-disable-next-line complexity - private encodeValue(value: any, contentType: string, encoding?: any): string { - switch (contentType) { - case 'application/json': - return JSON.stringify(value); - - case 'application/x-www-form-urlencoded': - return stringify(value, { - format: 'RFC3986', - encode: false - }); - - case 'application/xml': - // eslint-disable-next-line no-case-declarations - const xmlOptions = { - header: true, - indent: ' ' - }; - - return toXML(value, xmlOptions); - - case 'multipart/form-data': - case 'multipart/mixin': - // eslint-disable-next-line no-case-declarations - const EOL = '\r\n'; - - // eslint-disable-next-line no-case-declarations - let rawData = Object.keys(value || {}) - .reduce((params: string[], key: string) => { - const multipartContentType = this.getMultipartContentType( - value[key], - key, - encoding - ); - - let param = `--${this.BOUNDARY}${EOL}`; - - switch (multipartContentType) { - case 'text/plain': - param += `Content-Disposition: form-data; name="${key}"${ - EOL + EOL - }`; - break; - case 'application/json': - param += `Content-Disposition: form-data; name="${key}"${EOL}`; - param += `Content-Type: ${multipartContentType}${EOL + EOL}`; - break; - default: { - param += `Content-Disposition: form-data; name="${key}"; filename="${key}"${EOL}`; - param += `Content-Type: ${multipartContentType}${EOL}`; - param += `Content-Transfer-Encoding: base64${EOL + EOL}`; - } - } - - param += - typeof value[key] === 'object' - ? JSON.stringify(value[key]) - : value[key]; - - params.push(param); - - return params; - }, [] as string[]) - .join(EOL); - - rawData += EOL; - rawData += `--${this.BOUNDARY}--`; - - return rawData; - - case 'image/jpg': - case 'image/jpeg': - return this.JPG_IMAGE; - - case 'image/png': - case 'image/*': - return this.PNG_IMAGE; - - default: - return typeof value === 'object' ? JSON.stringify(value) : value; - } - } - - private getMultipartContentType( - value: any, - paramKey: string, - encoding: any - ): string { - if (encoding && encoding[paramKey] && encoding[paramKey].contentType) { - return encoding[paramKey].contentType; - } - - switch (typeof value) { - case 'object': - return 'application/json'; - case 'string': - return this.BASE64_PATTERN.test(value) - ? 'application/octet-stream' - : 'text/plain'; - case 'number': - case 'boolean': - return 'text/plain'; - default: - return 'application/octet-stream'; - } - } - - private encodeProperties(keys: string[], data: any, encoding: any): string { - // eslint-disable-next-line @typescript-eslint/no-shadow - const encodedSample = keys.reduce((encodedSample, encodingKey) => { - encodedSample[encodingKey] = this.encodeValue( - data[encodingKey], - encoding[encodingKey].contentType - ); - - return encodedSample; - }, {}); - - return Object.assign({}, data, encodedSample); - } - - // eslint-disable-next-line complexity - private getQueryStrings( - spec: OpenAPI.Document, - path: string, - method: string, - values: Record = {} - ): QueryString[] { - const queryStrings: QueryString[] = []; - const pathObj = spec.paths[path][method]; - const tokens = ['paths', path, method]; - const params = Array.isArray(pathObj.parameters) ? pathObj.parameters : []; - const oas3 = this.isOASV3(spec); - - for (const param of params) { - if (typeof param.in === 'string' && param.in.toLowerCase() === 'query') { - const data = this.sampleParam(param, { - spec, - tokens, - idx: params.indexOf(param) - }); - - if (typeof values[param.name] !== 'undefined') { - queryStrings.push({ - name: param.name, - value: `${values[param.name]}` - }); - } else if (typeof param.default === 'undefined') { - queryStrings.push( - ...this.createQueryStringEntries( - param.name, - this.serializeParamValue(param, data, oas3) - ) - ); - } else { - queryStrings.push({ - name: param.name, - value: `${param.default}` - }); - } - } - } - - return queryStrings; - } - - // eslint-disable-next-line complexity - private getHeadersArray( - spec: OpenAPI.Document, - path: string, - method: string - ): Header[] { - const headers: Header[] = []; - const pathObj = spec.paths[path][method]; - const tokens = ['paths', path, method]; - - // 'content-type' header: - if (Array.isArray(pathObj.consumes)) { - for (const value of pathObj.consumes) { - headers.push({ - value, - name: 'content-type' - }); - } - } - - // 'accept' header: - if (Array.isArray(pathObj.produces)) { - for (const value of pathObj.produces) { - headers.push({ - value, - name: 'accept' - }); - } - } - - // v3 'content-type' header: - if (pathObj.requestBody?.content) { - for (const value of Object.keys(pathObj.requestBody.content)) { - headers.push({ - value, - name: 'content-type' - }); - } - } - - const params = Array.isArray(pathObj.parameters) ? pathObj.parameters : []; - - // headers defined in path object: - for (const param of params) { - if (typeof param.in === 'string' && param.in.toLowerCase() === 'header') { - const data = this.sampleParam(param, { - spec, - tokens, - idx: params.indexOf(param) - }); - - headers.push({ - name: param.name.toLowerCase(), - value: typeof data === 'object' ? JSON.stringify(data) : data - }); - } - } - - // security: - let securityObj: Record[]; - - if (Array.isArray(pathObj.security)) { - securityObj = pathObj.security; - } else if (Array.isArray(spec.security)) { - securityObj = spec.security; - } - - if (!securityObj) { - return headers; - } - - let definedSchemes; - - if (this.isOASV2(spec) && spec.securityDefinitions) { - definedSchemes = spec.securityDefinitions; - } else if (this.isOASV3(spec) && spec.components) { - definedSchemes = spec.components.securitySchemes; - } - - if (!definedSchemes) { - return headers; - } - - let basicAuthDef; - let apiKeyAuthDef; - let oauthDef; - - for (const obj of securityObj) { - const secScheme = Object.keys(obj)[0]; - const secDefinition = definedSchemes[secScheme]; - const authType = (secDefinition as any).type?.toLowerCase(); - switch (authType) { - case 'http': - // eslint-disable-next-line no-case-declarations - const authScheme = (secDefinition as any).scheme?.toLowerCase(); - switch (authScheme) { - case 'bearer': - oauthDef = secScheme; - break; - case 'basic': - basicAuthDef = secScheme; - break; - } - break; - case 'basic': - basicAuthDef = secScheme; - break; - case 'apikey': - if ((secDefinition as any).in === 'header') { - apiKeyAuthDef = secDefinition; - } - break; - case 'oauth2': - oauthDef = secScheme; - break; - } - } - - if (basicAuthDef) { - headers.push({ - name: 'authorization', - value: `Basic REPLACE_BASIC_AUTH` - }); - } else if (typeof (apiKeyAuthDef as any)?.name === 'string') { - headers.push({ - name: (apiKeyAuthDef as any).name.toLowerCase(), - value: 'REPLACE_KEY_VALUE' - }); - } else if (oauthDef) { - headers.push({ - name: 'authorization', - value: `Bearer REPLACE_BEARER_TOKEN` - }); - } - - return headers; - } - - private sampleParam( - param: OpenAPI.Parameter, - context: { - spec: OpenAPI.Document; - idx: number; - tokens: string[]; - } - ): any { - return this.sample('schema' in param ? param.schema : param, { - spec: context.spec, - jsonPointer: pointer.compile([ - ...context.tokens, - 'parameters', - context.idx.toString(), - ...('schema' in param ? ['schema'] : []) - ]) - }); - } - - private getDelimiter(style: string, oas3: boolean): string { - switch (style) { - case 'spaceDelimited': - case 'ssv': - return ' '; - case 'tsv': - return '\t'; - case 'pipeDelimited': - case 'pipes': - return '|'; - case 'csv': - case 'form': - return ','; - default: - return oas3 ? '&' : ','; - } - } - - private serializeParamValue( - param: OpenAPIV2.Parameter | OpenAPIV3.ParameterObject, - value: any, - oas3: boolean - ): any { - const style = oas3 - ? param.style - : (param as OpenAPIV2.Parameter).collectionFormat; - const explode = oas3 - ? param.explode - : (param as OpenAPIV2.Parameter).collectionFormat === 'multi'; - - if (explode) { - return value; - } - - const delimiter = this.getDelimiter(style, oas3); - if (Array.isArray(value)) { - return value.join(delimiter); - } else if (isObject(value)) { - return this.flattener.toFlattenArray(value).join(delimiter); - } - - return value; - } - - private createQueryStringEntries(name: string, value: any): QueryString[] { - let values: QueryString[]; - - if (isObject(value)) { - const flatten = this.flattener.toFlattenObject(value, { - format: 'indices' - }); - values = Object.entries(flatten).map(([n, x]: any[]) => ({ - name: n, - value: `${x}` - })); - } else if (Array.isArray(value)) { - values = value.map((x) => ({ name, value: `${x}` })); - } else { - values = [ - { - name, - value: `${value}` - } - ]; - } - - return values; - } - - private serializePath( - spec: OpenAPI.Document, - path: string, - method: string - ): string { - const pathObj = spec.paths[path][method]; - const params: (OpenAPIV2.Parameter | OpenAPIV3.ParameterObject)[] = - Array.isArray(pathObj.parameters) ? pathObj.parameters : []; - const pathParams = params.filter( - (p) => typeof p.in === 'string' && p.in.toLowerCase() === 'path' - ); - - const tokens = ['paths', path, method]; - const sampledParams = pathParams.map((param) => - this.sampleParam(param, { - spec, - tokens, - idx: params.indexOf(param) - }) - ); - - return this.isOASV2(spec) - ? this.serializeOas2Path( - path, - pathParams as OpenAPIV2.Parameter[], - sampledParams - ) - : this.serializeOas3Path( - path, - pathParams as OpenAPIV3.ParameterObject[], - sampledParams + this.sampler, + this.paramsSerializer ); - } - - private serializeOas2Path( - path: string, - pathParams: OpenAPIV2.Parameter[], - sampledParams: any[] - ): string { - return encodeURI( - pathParams.reduce( - (res, param, idx) => - res.replace( - `{${param.name}}`, - this.serializeParamValue(param, sampledParams[idx], false) - ), - path - ) - ); - } - - private serializeOas3Path( - path: string, - pathParams: OpenAPIV3.ParameterObject[], - sampledParams: any[] - ): string { - const uriTemplatePath = pathParams.reduce( - (res, param) => - res.replace(`{${param.name}}`, this.getParamUriTemplate(param)), - path - ); - - return template.parse(uriTemplatePath).expand( - pathParams.reduce( - (res: Record, param, idx) => ({ - ...res, - [param.name]: sampledParams[idx] - }), - {} - ) - ); - } - - private getParamUriTemplate({ - name, - style, - explode - }: OpenAPIV2.Parameter | OpenAPIV3.ParameterObject): string { - const suffix = explode ? '*' : ''; - - let prefix; - switch (style) { - case 'label': - prefix = '.'; - break; - case 'matrix': - prefix = ';'; - break; - case 'simple': - default: - prefix = ''; } - - return `{${prefix}${name}${suffix}}`; - } - - private getBaseUrl(spec: OpenAPI.Document): string { - const urls: string[] = this.parseUrls(spec); - - if (!Array.isArray(urls) || !urls.length) { - throw new ConvertError( - 'Target must be specified', - this.isOASV2(spec) ? '/host' : '/servers' - ); - } - - let preferredUrls: string[] = urls.filter( - (x) => x.startsWith('https') || x.startsWith('wss') - ); - - if (!preferredUrls.length) { - preferredUrls = urls; - } - - return this.sample({ - type: 'array', - examples: preferredUrls - }); - } - - private parseUrls(spec: OpenAPI.Document): string[] { - if (this.isOASV3(spec) && spec.servers?.length) { - return this.parseServers(spec); - } - - if (this.isOASV2(spec) && spec.host) { - return this.parseHost(spec); - } - - return []; - } - - private parseHost(spec: OpenAPIV2.Document): string[] { - const basePath = removeLeadingSlash( - typeof spec.basePath === 'string' ? spec.basePath : '' - ).trim(); - const host = removeTrailingSlash( - typeof spec.host === 'string' ? spec.host : '' - ).trim(); - - if (!host) { - throw new ConvertError('Missing mandatory `host` field', '/host'); - } - - const schemes: string[] = Array.isArray(spec.schemes) - ? spec.schemes - : ['https']; - - return schemes.map((x: string, idx: number) => - this.normalizeUrl(`${x}://${host}/${basePath}`, { - jsonPointer: pointer.compile(['schemes', idx.toString()]) - }) - ); - } - - private parseServers(spec: OpenAPIV3.Document): string[] { - return spec.servers.map((server: OpenAPIV3.ServerObject, idx: number) => { - const variables = server.variables || {}; - const params = Object.entries(variables).reduce( - (acc, [param, variable]: [string, OpenAPIV3.ServerVariableObject]) => ({ - ...acc, - [param]: this.sample(variable, { - spec, - jsonPointer: pointer.compile([ - 'servers', - idx.toString(), - 'variables', - param - ]) - }) - }), - {} - ); - const templateUrl = template.parse(server.url); - const rawUrl = templateUrl.expand(params); - const jsonPointer = pointer.compile(['servers', idx.toString()]); - - return this.normalizeUrl(rawUrl, { jsonPointer }); - }); - } - - /** - * To exclude extra fields that are used in response only, {@link Options.skipReadOnly} must be used. - * @see {@link https://swagger.io/docs/specification/data-models/data-types/#readonly-writeonly | Read-Only and Write-Only Properties} - */ - private sample( - schema: Schema, - context?: { - spec?: OpenAPI.Document; - jsonPointer?: string; - } - ): any | undefined { - try { - return sample(schema, { skipReadOnly: true, quiet: true }, context?.spec); - } catch (e) { - throw new ConvertError(e.message, context?.jsonPointer); - } - } - - private isOASV2(doc: OpenAPI.Document): doc is OpenAPIV2.Document { - return 'swagger' in doc; } - private isOASV3(doc: OpenAPI.Document): doc is OpenAPIV3.Document { - return 'openapi' in doc; + private convertPart(type: SubPart, path: string, method: string): T { + return this.subConverters.get(type).convert(path, method) as unknown as T; } } diff --git a/packages/oas/src/converter/ParamsSerializer.ts b/packages/oas/src/converter/ParamsSerializer.ts new file mode 100644 index 00000000..81597458 --- /dev/null +++ b/packages/oas/src/converter/ParamsSerializer.ts @@ -0,0 +1,50 @@ +import { isObject, Flattener } from '../utils'; +import { OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; + +export class ParamsSerializer { + private readonly flattener = new Flattener(); + + public serializeValue( + param: OpenAPIV2.Parameter | OpenAPIV3.ParameterObject, + value: any, + oas3: boolean + ): any { + const style = oas3 + ? param.style + : (param as OpenAPIV2.Parameter).collectionFormat; + const explode = oas3 + ? param.explode + : (param as OpenAPIV2.Parameter).collectionFormat === 'multi'; + + if (explode) { + return value; + } + + const delimiter = this.getDelimiter(style, oas3); + if (Array.isArray(value)) { + return value.join(delimiter); + } else if (isObject(value)) { + return this.flattener.toFlattenArray(value).join(delimiter); + } + + return value; + } + + private getDelimiter(style: string, oas3: boolean): string { + switch (style) { + case 'spaceDelimited': + case 'ssv': + return ' '; + case 'tsv': + return '\t'; + case 'pipeDelimited': + case 'pipes': + return '|'; + case 'csv': + case 'form': + return ','; + default: + return oas3 ? '&' : ','; + } + } +} diff --git a/packages/oas/src/converter/Sampler.ts b/packages/oas/src/converter/Sampler.ts new file mode 100644 index 00000000..02cb8e3c --- /dev/null +++ b/packages/oas/src/converter/Sampler.ts @@ -0,0 +1,43 @@ +import { ConvertError } from '../errors'; +import { sample, Schema } from '@har-sdk/openapi-sampler'; +import pointer from 'json-pointer'; +import { OpenAPI } from '@har-sdk/core'; + +export class Sampler { + public sampleParam( + param: OpenAPI.Parameter, + context: { + spec: OpenAPI.Document; + idx: number; + tokens: string[]; + } + ): any { + return this.sample('schema' in param ? param.schema : param, { + spec: context.spec, + jsonPointer: pointer.compile([ + ...context.tokens, + 'parameters', + context.idx.toString(), + ...('schema' in param ? ['schema'] : []) + ]) + }); + } + + /** + * To exclude extra fields that are used in response only, {@link Options.skipReadOnly} must be used. + * @see {@link https://swagger.io/docs/specification/data-models/data-types/#readonly-writeonly | Read-Only and Write-Only Properties} + */ + public sample( + schema: Schema, + context?: { + spec?: OpenAPI.Document; + jsonPointer?: string; + } + ): any | undefined { + try { + return sample(schema, { skipReadOnly: true, quiet: true }, context?.spec); + } catch (e) { + throw new ConvertError(e.message, context?.jsonPointer); + } + } +} diff --git a/packages/oas/src/converter/subconverters/HeadersConverter.ts b/packages/oas/src/converter/subconverters/HeadersConverter.ts new file mode 100644 index 00000000..001f276c --- /dev/null +++ b/packages/oas/src/converter/subconverters/HeadersConverter.ts @@ -0,0 +1,176 @@ +import { isOASV2, isOASV3 } from '../../utils'; +import { Sampler } from '../Sampler'; +import { SubConverter } from './SubConverter'; +import { Header, OpenAPI, OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; + +type OperationObject = OpenAPIV2.OperationObject | OpenAPIV3.OperationObject; + +type SecurityRequirementObject = + | OpenAPIV2.SecurityRequirementObject + | OpenAPIV3.SecurityRequirementObject; + +type SecuritySchemeObject = + | OpenAPIV2.SecuritySchemeObject + | OpenAPIV3.SecuritySchemeObject; + +type SecuritySchemeApiKey = + | OpenAPIV2.SecuritySchemeApiKey + | OpenAPIV3.ApiKeySecurityScheme; + +export class HeadersConverter implements SubConverter { + constructor( + private readonly spec: OpenAPI.Document, + private readonly sampler: Sampler + ) {} + + public convert(path: string, method: string): Header[] { + const headers: Header[] = []; + const pathObj = this.spec.paths[path][method]; + + if (Array.isArray(pathObj.consumes)) { + for (const value of pathObj.consumes) { + headers.push(this.createHeader('content-type', value)); + } + } else if (pathObj.requestBody?.content) { + for (const value of Object.keys(pathObj.requestBody.content)) { + headers.push(this.createHeader('content-type', value)); + } + } + + if (Array.isArray(pathObj.produces)) { + for (const value of pathObj.produces) { + headers.push(this.createHeader('accept', value)); + } + } + + headers.push( + ...this.createFromPathParams(pathObj, ['paths', path, method]) + ); + headers.push(...this.createFromSecurityRequirements(pathObj)); + + return headers; + } + + private createFromPathParams( + pathObj: OpenAPIV2.OperationObject | OpenAPIV3.OperationObject, + tokens: string[] + ): Header[] { + const params: (OpenAPIV3.ParameterObject | OpenAPIV2.Parameter)[] = ( + Array.isArray(pathObj.parameters) ? pathObj.parameters : [] + ) as (OpenAPIV3.ParameterObject | OpenAPIV2.Parameter)[]; + + return params + .filter( + (param) => + typeof param.in === 'string' && param.in.toLowerCase() === 'header' + ) + .map((param) => ({ + name: param.name.toLowerCase(), + value: this.serializeHeaderValue( + this.sampler.sampleParam(param, { + spec: this.spec, + tokens, + idx: params.indexOf(param) + }) + ) + })); + } + + private serializeHeaderValue(value: any): string { + // TODO proper serialization + return typeof value === 'object' ? JSON.stringify(value) : value; + } + + private getSecurityRequirementObjects( + pathObj: OperationObject + ): SecurityRequirementObject[] | undefined { + if (Array.isArray(pathObj.security)) { + return pathObj.security; + } else if (Array.isArray(this.spec.security)) { + return this.spec.security; + } + } + + private getSecuritySchemes(): + | Record + | undefined { + if (isOASV2(this.spec) && this.spec.securityDefinitions) { + return this.spec.securityDefinitions; + } else if (isOASV3(this.spec) && this.spec.components) { + return this.spec.components.securitySchemes as Record< + string, + SecuritySchemeObject + >; + } + } + + private createFromSecurityRequirements(pathObj: OperationObject): Header[] { + const secRequirementObjects = this.getSecurityRequirementObjects(pathObj); + if (!secRequirementObjects) { + return []; + } + + const securitySchemes = this.getSecuritySchemes(); + if (!securitySchemes) { + return []; + } + + for (const obj of secRequirementObjects) { + const header = this.createFromSecurityRequirement(obj, securitySchemes); + if (header) { + return [header]; + } + } + } + + private createFromSecurityRequirement( + obj: SecurityRequirementObject, + securitySchemes: Record + ): Header | undefined { + const schemeName = Object.keys(obj)[0]; + const securityScheme = securitySchemes[schemeName]; + const authType = securityScheme.type.toLowerCase(); + switch (authType) { + case 'http': + switch ( + (securityScheme as OpenAPIV3.HttpSecurityScheme).scheme?.toLowerCase() + ) { + case 'bearer': + return this.createBearerAuthHeader(); + case 'basic': + return this.createBasicAuthHeader(); + } + break; + case 'basic': + return this.createBasicAuthHeader(); + case 'apikey': + if ((securityScheme as SecuritySchemeApiKey).in === 'header') { + return this.createApiKeyHeader( + securityScheme as SecuritySchemeApiKey + ); + } + break; + case 'oauth2': + return this.createBearerAuthHeader(); + } + } + + private createBearerAuthHeader(): Header { + return this.createHeader('authorization', 'Bearer REPLACE_BEARER_TOKEN'); + } + + private createBasicAuthHeader(): Header { + return this.createHeader('authorization', 'Basic REPLACE_BASIC_AUTH'); + } + + private createApiKeyHeader(scheme: SecuritySchemeApiKey): Header { + return this.createHeader(scheme.name.toLowerCase(), 'REPLACE_KEY_VALUE'); + } + + private createHeader(name: string, value: string): Header { + return { + name, + value + }; + } +} diff --git a/packages/oas/src/converter/subconverters/PathConverter.ts b/packages/oas/src/converter/subconverters/PathConverter.ts new file mode 100644 index 00000000..beeee5c4 --- /dev/null +++ b/packages/oas/src/converter/subconverters/PathConverter.ts @@ -0,0 +1,111 @@ +import { isOASV2 } from '../../utils'; +import { Sampler } from '../Sampler'; +import { ParamsSerializer } from '../ParamsSerializer'; +import { SubConverter } from './SubConverter'; +import { OpenAPI, OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; +import template from 'url-template'; + +export class PathConverter implements SubConverter { + constructor( + private readonly spec: OpenAPI.Document, + private readonly sampler: Sampler, + private readonly paramsSerializer: ParamsSerializer + ) {} + + public convert(path: string, method: string): string { + const pathObj = this.spec.paths[path][method]; + const params: (OpenAPIV2.Parameter | OpenAPIV3.ParameterObject)[] = + Array.isArray(pathObj.parameters) ? pathObj.parameters : []; + const pathParams = params.filter( + (p) => typeof p.in === 'string' && p.in.toLowerCase() === 'path' + ); + + const tokens = ['paths', path, method]; + const sampledParams = pathParams.map((param) => + this.sampler.sampleParam(param, { + spec: this.spec, + tokens, + idx: params.indexOf(param) + }) + ); + + return isOASV2(this.spec) + ? this.serializeOas2Path( + path, + pathParams as OpenAPIV2.Parameter[], + sampledParams + ) + : this.serializeOas3Path( + path, + pathParams as OpenAPIV3.ParameterObject[], + sampledParams + ); + } + + private serializeOas2Path( + path: string, + pathParams: OpenAPIV2.Parameter[], + sampledParams: any[] + ): string { + return encodeURI( + pathParams.reduce( + (res, param, idx) => + res.replace( + `{${param.name}}`, + this.paramsSerializer.serializeValue( + param, + sampledParams[idx], + false + ) + ), + path + ) + ); + } + + // TODO extract URI template logic + private serializeOas3Path( + path: string, + pathParams: OpenAPIV3.ParameterObject[], + sampledParams: any[] + ): string { + const uriTemplatePath = pathParams.reduce( + (res, param) => + res.replace(`{${param.name}}`, this.getParamUriTemplate(param)), + path + ); + + return template.parse(uriTemplatePath).expand( + pathParams.reduce( + (res: Record, param, idx) => ({ + ...res, + [param.name]: sampledParams[idx] + }), + {} + ) + ); + } + + private getParamUriTemplate({ + name, + style, + explode + }: OpenAPIV2.Parameter | OpenAPIV3.ParameterObject): string { + const suffix = explode ? '*' : ''; + + let prefix; + switch (style) { + case 'label': + prefix = '.'; + break; + case 'matrix': + prefix = ';'; + break; + case 'simple': + default: + prefix = ''; + } + + return `{${prefix}${name}${suffix}}`; + } +} diff --git a/packages/oas/src/converter/subconverters/PostDataConverter.ts b/packages/oas/src/converter/subconverters/PostDataConverter.ts new file mode 100644 index 00000000..71aa3fd6 --- /dev/null +++ b/packages/oas/src/converter/subconverters/PostDataConverter.ts @@ -0,0 +1,231 @@ +import { Sampler } from '../Sampler'; +import { isOASV2 } from '../../utils'; +import { SubConverter } from './SubConverter'; +import { OpenAPI, PostData } from '@har-sdk/core'; +import pointer from 'json-pointer'; +import { toXML } from 'jstoxml'; +import { stringify } from 'qs'; + +export class PostDataConverter implements SubConverter { + private readonly JPG_IMAGE = '/9j/2w=='; + private readonly PNG_IMAGE = 'iVBORw0KGgo='; + private readonly BOUNDARY = '956888039105887155673143'; + private readonly BASE64_PATTERN = + /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/; + + constructor( + private readonly spec: OpenAPI.Document, + private readonly sampler: Sampler + ) {} + + // eslint-disable-next-line complexity + public convert(path: string, method: string): PostData | null { + const pathObj = this.spec.paths[path][method]; + const tokens = ['paths', path, method]; + const params = Array.isArray(pathObj.parameters) ? pathObj.parameters : []; + + for (const param of params) { + if ( + typeof param.in === 'string' && + param.in.toLowerCase() === 'body' && + 'schema' in param + ) { + const data = this.sampler.sampleParam(param, { + spec: this.spec, + tokens, + idx: pathObj.parameters.indexOf(param) + }); + + let consumes; + + // eslint-disable-next-line max-depth + if (pathObj.consumes?.length) { + consumes = pathObj.consumes; + } else if (isOASV2(this.spec) && this.spec.consumes?.length) { + consumes = this.spec.consumes; + } + + const paramContentType = this.sampler.sample({ + type: 'array', + examples: consumes || ['application/json'] + }); + + return this.encodePayload(data, paramContentType); + } + } + + const content = pathObj.requestBody?.content ?? {}; + const keys = Object.keys(content); + + if (!keys.length) { + return null; + } + + const contentType = this.sampler.sample({ + type: 'array', + examples: keys + }); + const sampleContent = content[contentType]; + + if (sampleContent?.schema) { + const data = this.sampler.sample(sampleContent.schema, { + spec: this.spec, + jsonPointer: pointer.compile([ + ...tokens, + 'requestBody', + 'content', + contentType, + 'schema' + ]) + }); + + return this.encodePayload(data, contentType, sampleContent.encoding); + } + + return null; + } + + private encodePayload( + data: any, + contentType: string, + encoding?: any + ): { mimeType: string; text: string } { + let encodedData = data; + + if (encoding) { + encodedData = this.encodeProperties( + Object.keys(encoding), + data, + encoding + ); + } + + return { + mimeType: contentType.includes('multipart') + ? `${contentType}; boundary=${this.BOUNDARY}` + : contentType, + text: this.encodeValue(encodedData, contentType, encoding) + }; + } + + // eslint-disable-next-line complexity + private encodeValue(value: any, contentType: string, encoding?: any): string { + switch (contentType) { + case 'application/json': + return JSON.stringify(value); + + case 'application/x-www-form-urlencoded': + return stringify(value, { + format: 'RFC3986', + encode: false + }); + + case 'application/xml': + // eslint-disable-next-line no-case-declarations + const xmlOptions = { + header: true, + indent: ' ' + }; + + return toXML(value, xmlOptions); + + case 'multipart/form-data': + case 'multipart/mixin': + // eslint-disable-next-line no-case-declarations + const EOL = '\r\n'; + + // eslint-disable-next-line no-case-declarations + let rawData = Object.keys(value || {}) + .reduce((params: string[], key: string) => { + const multipartContentType = this.getMultipartContentType( + value[key], + key, + encoding + ); + + let param = `--${this.BOUNDARY}${EOL}`; + + switch (multipartContentType) { + case 'text/plain': + param += `Content-Disposition: form-data; name="${key}"${ + EOL + EOL + }`; + break; + case 'application/json': + param += `Content-Disposition: form-data; name="${key}"${EOL}`; + param += `Content-Type: ${multipartContentType}${EOL + EOL}`; + break; + default: { + param += `Content-Disposition: form-data; name="${key}"; filename="${key}"${EOL}`; + param += `Content-Type: ${multipartContentType}${EOL}`; + param += `Content-Transfer-Encoding: base64${EOL + EOL}`; + } + } + + param += + typeof value[key] === 'object' + ? JSON.stringify(value[key]) + : value[key]; + + params.push(param); + + return params; + }, [] as string[]) + .join(EOL); + + rawData += EOL; + rawData += `--${this.BOUNDARY}--`; + + return rawData; + + case 'image/jpg': + case 'image/jpeg': + return this.JPG_IMAGE; + + case 'image/png': + case 'image/*': + return this.PNG_IMAGE; + + default: + return typeof value === 'object' ? JSON.stringify(value) : value; + } + } + + private getMultipartContentType( + value: any, + paramKey: string, + encoding: any + ): string { + if (encoding && encoding[paramKey] && encoding[paramKey].contentType) { + return encoding[paramKey].contentType; + } + + switch (typeof value) { + case 'object': + return 'application/json'; + case 'string': + return this.BASE64_PATTERN.test(value) + ? 'application/octet-stream' + : 'text/plain'; + case 'number': + case 'boolean': + return 'text/plain'; + default: + return 'application/octet-stream'; + } + } + + private encodeProperties(keys: string[], data: any, encoding: any): string { + // eslint-disable-next-line @typescript-eslint/no-shadow + const encodedSample = keys.reduce((encodedSample, encodingKey) => { + encodedSample[encodingKey] = this.encodeValue( + data[encodingKey], + encoding[encodingKey].contentType + ); + + return encodedSample; + }, {}); + + return Object.assign({}, data, encodedSample); + } +} diff --git a/packages/oas/src/converter/subconverters/QueryStringConverter.ts b/packages/oas/src/converter/subconverters/QueryStringConverter.ts new file mode 100644 index 00000000..2ce415bb --- /dev/null +++ b/packages/oas/src/converter/subconverters/QueryStringConverter.ts @@ -0,0 +1,85 @@ +import { isOASV3, isObject, Flattener } from '../../utils'; +import { Sampler } from '../Sampler'; +import { ParamsSerializer } from '../ParamsSerializer'; +import { SubConverter } from './SubConverter'; +import { OpenAPI, OpenAPIV2, OpenAPIV3, QueryString } from '@har-sdk/core'; + +export class QueryStringConverter implements SubConverter { + private readonly flattener = new Flattener(); + + constructor( + private readonly spec: OpenAPI.Document, + private readonly sampler: Sampler, + private readonly paramsSerializer: ParamsSerializer + ) {} + + public convert(path: string, method: string): QueryString[] { + const pathObj = this.spec.paths[path][method]; + const tokens = ['paths', path, method]; + const params: (OpenAPIV2.Parameter | OpenAPIV3.ParameterObject)[] = + Array.isArray(pathObj.parameters) ? pathObj.parameters : []; + const oas3 = isOASV3(this.spec); + + return params + .filter( + (param) => + typeof param.in === 'string' && param.in.toLowerCase() === 'query' + ) + .flatMap((param) => { + const value = oas3 + ? this.getOas3ParameterValue(param as OpenAPIV3.ParameterObject) + : this.getOas2ParameterValue(param as OpenAPIV2.Parameter); + + return this.convertQueryParam( + param.name, + this.paramsSerializer.serializeValue( + param, + value ?? + this.sampler.sampleParam(param, { + spec: this.spec, + tokens, + idx: params.indexOf(param) + }), + oas3 + ) + ); + }); + } + + private getOas2ParameterValue(param: OpenAPIV2.Parameter): any { + if (param.default !== 'undefined') { + return param.default; + } + } + + private getOas3ParameterValue(param: OpenAPIV3.ParameterObject): any { + if (param.example !== 'undefined') { + return param.example; + } + } + + private convertQueryParam(name: string, value: any): QueryString[] { + let values: QueryString[]; + + if (isObject(value)) { + const flatten = this.flattener.toFlattenObject(value, { + format: 'indices' + }); + values = Object.entries(flatten).map(([n, x]: any[]) => ({ + name: n, + value: `${x}` + })); + } else if (Array.isArray(value)) { + values = value.map((x) => ({ name, value: `${x}` })); + } else { + values = [ + { + name, + value: `${value}` + } + ]; + } + + return values; + } +} diff --git a/packages/oas/src/converter/subconverters/SubConverter.ts b/packages/oas/src/converter/subconverters/SubConverter.ts new file mode 100644 index 00000000..198004a5 --- /dev/null +++ b/packages/oas/src/converter/subconverters/SubConverter.ts @@ -0,0 +1,3 @@ +export interface SubConverter { + convert(path: string, method: string): R; +} diff --git a/packages/oas/src/converter/subconverters/SubPart.ts b/packages/oas/src/converter/subconverters/SubPart.ts new file mode 100644 index 00000000..36094dbe --- /dev/null +++ b/packages/oas/src/converter/subconverters/SubPart.ts @@ -0,0 +1,6 @@ +export enum SubPart { + HEADERS = 'HEADERS', + PATH = 'PATH', + POST_DATA = 'POST_DATA', + QUERY_STRING = 'QUERY_STRING' +} diff --git a/packages/oas/src/converter/subconverters/index.ts b/packages/oas/src/converter/subconverters/index.ts new file mode 100644 index 00000000..7d5f67ce --- /dev/null +++ b/packages/oas/src/converter/subconverters/index.ts @@ -0,0 +1,6 @@ +export { HeadersConverter } from './HeadersConverter'; +export { PathConverter } from './PathConverter'; +export { PostDataConverter } from './PostDataConverter'; +export { QueryStringConverter } from './QueryStringConverter'; +export { SubConverter } from './SubConverter'; +export { SubPart } from './SubPart'; diff --git a/packages/oas/src/utils/index.ts b/packages/oas/src/utils/index.ts index 9202fce8..e7d0db81 100644 --- a/packages/oas/src/utils/index.ts +++ b/packages/oas/src/utils/index.ts @@ -1,2 +1,10 @@ +import { OpenAPI, OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; + export * from './Flattener'; export * from './isObject'; + +// TODO extract to core? +export const isOASV2 = (doc: OpenAPI.Document): doc is OpenAPIV2.Document => + 'swagger' in doc; +export const isOASV3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document => + 'openapi' in doc;