From 28a98451efa0746f026a81795b78d4da1eaa238c Mon Sep 17 00:00:00 2001 From: Artem Derevnjuk Date: Wed, 1 Jul 2020 17:41:35 +0300 Subject: [PATCH] feat: implement postman collection converter (#1) --- README.md | 39 +- package-lock.json | 71 ++- package.json | 5 + src/.gitkeep | 0 src/converter/Converter.ts | 5 + src/converter/DefaultConverter.ts | 529 +++++++++++++++++++++ src/converter/index.ts | 2 + src/index.ts | 24 + src/parser/BaseVariableParser.ts | 29 ++ src/parser/DefaultVariableParserFactory.ts | 18 + src/parser/EnvVariableParser.ts | 37 ++ src/parser/Replacer.ts | 51 ++ src/parser/UrlVariableParser.ts | 34 ++ src/parser/VariableParser.ts | 5 + src/parser/VariableParserFactory.ts | 7 + src/parser/index.ts | 5 + src/types/postman.d.ts | 208 ++++++++ src/validator/DefaultValidator.ts | 37 ++ src/validator/Validator.ts | 3 + src/validator/index.ts | 2 + tsconfig.json | 7 +- 21 files changed, 1109 insertions(+), 9 deletions(-) delete mode 100644 src/.gitkeep create mode 100644 src/converter/Converter.ts create mode 100644 src/converter/DefaultConverter.ts create mode 100644 src/converter/index.ts create mode 100644 src/index.ts create mode 100644 src/parser/BaseVariableParser.ts create mode 100644 src/parser/DefaultVariableParserFactory.ts create mode 100644 src/parser/EnvVariableParser.ts create mode 100644 src/parser/Replacer.ts create mode 100644 src/parser/UrlVariableParser.ts create mode 100644 src/parser/VariableParser.ts create mode 100644 src/parser/VariableParserFactory.ts create mode 100644 src/parser/index.ts create mode 100644 src/types/postman.d.ts create mode 100644 src/validator/DefaultValidator.ts create mode 100644 src/validator/Validator.ts create mode 100644 src/validator/index.ts diff --git a/README.md b/README.md index 8aaf484..50c4703 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ # @neuralegion/postman2har -Service lib description +Transform you Postman collection to a series of HAR request objects. + +- https://schema.getpostman.com/collection/json/v2.1.0/draft-07/docs/index.html +- http://www.softwareishard.com/blog/har-12-spec/#request + +## Setup + +```bash +npm i --save @neuralegion/postman2har +``` + +## 🚀 Usage + +Using as a ES module: + +```js +import { postman2har } from '@neuralegion/postman2har'; +import collection from 'your-postman-collection.json'; + +postman2har(collection).then((requests) => { + console.log(requests); +}); +``` + +If you want to pass additional data to resolve environment variables you can pass them via options: +```js +postman2har(collection, { + environment: { baseUrl: 'https://example.com' } +}).then((requests) => { + console.log(requests); +}); +``` + +## 📝License + +Copyright © 2020 [NeuraLegion](https://github.com/NeuraLegion). + +This project is licensed under the MIT License - see the [LICENSE file](LICENSE) for details. diff --git a/package-lock.json b/package-lock.json index fddd6a1..732df91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -299,6 +299,14 @@ "dev": true, "requires": { "semver": "6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } } }, "@commitlint/lint": { @@ -745,6 +753,18 @@ "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", "dev": true }, + "@types/faker": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/faker/-/faker-4.1.12.tgz", + "integrity": "sha512-0MEyzJrLLs1WaOCx9ULK6FzdCSj2EuxdSP9kvuxxdBEGujZYUOZ4vkPXdgu3dhyg/pOdn7VCatelYX7k0YShlA==", + "dev": true + }, + "@types/har-format": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.4.tgz", + "integrity": "sha512-iUxzm1meBm3stxUMzRqgOVHjj4Kgpgu5w9fm4X7kPRfSgVRzythsucEN7/jtOo8SQzm+HfcxWWzJS0mJDH/3DQ==", + "dev": true + }, "@types/json-schema": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz", @@ -793,6 +813,15 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "dev": true }, + "@types/semver": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.1.tgz", + "integrity": "sha512-ooD/FJ8EuwlDKOI6D9HWxgIgJjMg2cuziXm/42npDC8y4NjxplBUn9loewZiBNCt44450lHAU0OSb51/UqXeag==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.4.0.tgz", @@ -1583,6 +1612,12 @@ "type-fest": "^0.13.1", "yargs-parser": "^18.1.3" } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, @@ -2419,6 +2454,11 @@ "strip-final-newline": "^2.0.0" } }, + "faker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", + "integrity": "sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8=" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3328,6 +3368,14 @@ "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.0.0", "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } } }, "istanbul-lib-processinfo": { @@ -3966,6 +4014,14 @@ "dev": true, "requires": { "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } } }, "make-error": { @@ -8964,10 +9020,9 @@ } }, "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" }, "semver-compare": { "version": "1.0.0", @@ -8982,6 +9037,14 @@ "dev": true, "requires": { "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } } }, "semver-regex": { diff --git a/package.json b/package.json index 7170c7c..bbbc334 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ }, "homepage": "https://github.com/NeuraLegion/postman2har#readme", "dependencies": { + "faker": "^4.1.0", + "semver": "^7.3.2", "tslib": "~1.11.1" }, "devDependencies": { @@ -56,8 +58,11 @@ "@types/chai": "^4.2.11", "@types/chai-as-promised": "^7.1.2", "@types/debug": "^4.1.5", + "@types/faker": "^4.1.12", + "@types/har-format": "^1.2.4", "@types/mocha": "~7.0.2", "@types/node": "~14.0.13", + "@types/semver": "^7.3.1", "@typescript-eslint/eslint-plugin": "^3.3.0", "@typescript-eslint/parser": "^3.3.0", "chai": "~4.2.0", diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/converter/Converter.ts b/src/converter/Converter.ts new file mode 100644 index 0000000..db8f9cd --- /dev/null +++ b/src/converter/Converter.ts @@ -0,0 +1,5 @@ +import HarV1 from 'har-format'; + +export interface Converter { + convert(collection: Postman.Collection): Promise; +} diff --git a/src/converter/DefaultConverter.ts b/src/converter/DefaultConverter.ts new file mode 100644 index 0000000..335409a --- /dev/null +++ b/src/converter/DefaultConverter.ts @@ -0,0 +1,529 @@ +import { Converter } from './Converter'; +import { Validator } from '../validator'; +import { VariableParser, VariableParserFactory } from '../parser'; +import Har from 'har-format'; +import { ok } from 'assert'; +import { format, parse, UrlObject } from 'url'; +import { parse as parseQS, ParsedUrlQuery, stringify } from 'querystring'; + +enum AuthLocation { + QUERY = 'queryString', + HEADER = 'headers' +} + +export class DefaultConverter implements Converter { + private readonly variables: ReadonlyArray; + private readonly DEFAULT_PROTOCOL = 'https'; + + constructor( + private readonly validator: Validator, + private readonly parserFactory: VariableParserFactory, + options: { + environment?: Record; + } + ) { + this.variables = Object.entries(options?.environment ?? {}).map( + ([key, value]: [string, string]) => ({ + key, + value + }) + ); + } + + public async convert(collection: Postman.Collection): Promise { + await this.validator.verify(collection); + + return this.traverse(collection, [...this.variables]); + } + + private traverse( + folder: Postman.ItemGroup, + variables: Postman.Variable[] + ): Har.Request[] { + variables = [...(folder?.variable ?? []), ...variables]; + + return folder.item.reduce( + (items: Har.Request[], x: Postman.ItemGroup | Postman.Item) => { + const subVariables = [...(x?.variable ?? []), ...variables]; + + if (this.isGroup(x)) { + return items.concat(this.traverse(x, subVariables)); + } + + const request: Har.Request | undefined = this.convertRequest( + x, + subVariables + ); + + if (request) { + items.push(request); + } + + return items; + }, + [] + ); + } + + private isGroup(x: any): x is Postman.ItemGroup { + return Array.isArray(x.item); + } + + private convertRequest( + item: Postman.Item, + variables: Postman.Variable[] + ): Har.Request | undefined { + if (item.request) { + const { method, url: urlObject, header, body, auth } = item.request; + + ok(method, 'Method is not defined.'); + + const url: string = this.convertUrl(urlObject, variables); + + const request: Har.Request = { + url, + method: (method ?? 'GET').toUpperCase(), + headers: this.convertHeaders(header!, variables), + queryString: this.convertQuery(url, variables), + cookies: [], + postData: body && this.convertBody(body, variables), + headersSize: -1, + bodySize: -1, + httpVersion: 'HTTP/1.1' + }; + + if (auth) { + this.authRequest(request, auth, variables); + } + + return request; + } + } + + private authRequest( + request: Har.Request, + auth: Postman.RequestAuth, + variables: Postman.Variable[] + ): void { + const params: Postman.Variable[] | undefined = auth[auth.type]; + + if (!params) { + return; + } + + const options: { [p: string]: string } = Object.fromEntries( + params.map((val: Postman.Variable) => [val.key, val.value]) + ); + + switch (auth.type) { + case 'apikey': + this.apiKeyAuth(request, options, variables); + break; + case 'basic': + this.basicAuth(request, options, variables); + break; + case 'bearer': + this.bearerAuth(request, options, variables); + break; + case 'oauth2': + this.oauth2(request, options, variables); + break; + case 'noauth': + default: + break; + } + } + + private oauth2( + request: Har.Request, + options: Record, + variables: Postman.Variable[] + ) { + if (!options.accessToken || options.tokenType === 'mac') { + return; + } + + const headerIdx: number = request.headers.findIndex( + (x: Har.Header) => x.name.toLowerCase() === 'authorization' + ); + + if (headerIdx !== -1) { + request.headers.splice(headerIdx, 1); + } + + const queryIdx: number = request.queryString.findIndex( + (x: Har.QueryString) => x.name.toLowerCase() === 'access_token' + ); + + if (queryIdx !== -1) { + request.queryString.splice(queryIdx, 1); + } + + const target: AuthLocation = + AuthLocation[(options.addTokenTo ?? 'header').toUpperCase()]; + + const parser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + + if (target === AuthLocation.QUERY) { + request.queryString.push({ + name: 'access_token', + value: parser.parse(options.accessToken) + }); + } + + if (target === AuthLocation.HEADER) { + const prefix: string = !options.headerPrefix + ? 'Bearer ' + : options.headerPrefix; + + request.headers.push({ + name: 'Authorization', + value: `${prefix.trim()} ${parser.parse(options.accessToken)}` + }); + } + } + + private bearerAuth( + request: Har.Request, + options: { [p: string]: string }, + variables: Postman.Variable[] + ): void { + const parser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + const idx: number = request.headers.findIndex( + (x: Har.Header) => x.name.toLowerCase() === 'authorization' + ); + + if (idx !== -1) { + request.headers.splice(idx, 1); + } + + const value: string = + 'Bearer ' + + parser + .parse(options.token) + .replace(/^Bearer/, '') + .trim(); + + request.headers.push({ + value, + name: 'Authorization' + }); + } + + private basicAuth( + request: Har.Request, + options: { [p: string]: string }, + variables: Postman.Variable[] + ) { + const parser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + const idx: number = request.headers.findIndex( + (x: Har.Header) => x.name.toLowerCase() === 'authorization' + ); + + if (idx !== -1) { + request.headers.splice(idx, 1); + } + + const value: string = + 'Basic ' + + Buffer.from( + `${parser.parse(options.username)}:${parser.parse(options.password)}`, + 'utf8' + ).toString('base64'); + + request.headers.push({ + value, + name: 'Authorization' + }); + } + + private apiKeyAuth( + request: Har.Request, + options: { [p: string]: string }, + variables: Postman.Variable[] + ): void { + const parser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + + const target: AuthLocation = + AuthLocation[(options.addTokenTo ?? 'header').toUpperCase()]; + + const idx: number = request[target].findIndex( + (x: Har.QueryString | Har.Header) => + x.name.toLowerCase() === options.key.toLowerCase() + ); + + if (idx !== -1) { + request[target].splice(idx, 1); + } + + request[target].push({ + name: parser.parse(options.key), + value: parser.parse(options.value) + }); + } + + private convertBody( + body: Postman.RequestBody, + variables: Postman.Variable[] + ): Har.PostData { + const parser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + + switch (body.mode) { + case 'raw': + return this.rawBody(body); + case 'urlencoded': + return this.urlencoded(body, parser); + case 'formdata': + return this.formData(body, parser); + case 'file': + return this.file(body); + default: + throw new Error('"mode" is not supported.'); + } + } + + private file(body: Postman.RequestBody): Har.PostData { + return { + mimeType: 'application/octet-stream', + params: [], + text: + (typeof body.file === 'string' ? body.file : body.file?.content) ?? '' + }; + } + + private formData( + body: Postman.RequestBody, + parser: VariableParser + ): Har.PostData { + return { + mimeType: 'multipart/form-data', + params: Array.isArray(body.formdata) + ? body.formdata.map((x: Postman.FormParam) => ({ + name: parser.parse(x.key ?? ''), + value: parser.parse(x.value ?? ''), + contentType: x.contentType + })) + : [], + text: '' + }; + } + + private urlencoded( + body: Postman.RequestBody, + parser: VariableParser + ): Har.PostData { + let params: { name: string; value: string | undefined }[]; + + if (Array.isArray(body.urlencoded)) { + params = body.urlencoded.map((x: Postman.QueryParam) => ({ + name: parser.parse(x.key ?? ''), + value: parser.parse(x.value ?? '') + })); + } else { + params = Object.entries(parseQS(body.urlencoded!)).map( + ([name, value]) => ({ + name, + value: Array.isArray(value) ? value.join('&') : value + }) + ); + } + + const text: string = + typeof body.urlencoded === 'string' + ? body.urlencoded + : stringify( + Object.fromEntries( + body.urlencoded!.map((x: Postman.QueryParam) => [ + parser.parse(x.key ?? ''), + parser.parse(x.value ?? '') + ]) + ) + ); + + return { + text, + params, + mimeType: 'application/x-www-form-urlencoded' + }; + } + + private rawBody(body: Postman.RequestBody): Har.PostData { + return { + params: [], + mimeType: this.getMimetype(body.options?.raw?.language ?? 'json'), + text: body.raw ?? '' + }; + } + + private getMimetype( + lang: 'json' | 'text' | 'javascript' | 'html' | 'xml' | string + ): string { + switch (lang) { + case 'json': + return 'application/json'; + case 'javascript': + case 'js': + return 'application/javascript'; + case 'html': + return 'text/html'; + case 'xml': + return 'application/xml'; + case 'text': + default: + return 'text/plain'; + } + } + + private convertHeaders( + headers: Postman.Header[] | string, + variables: Postman.Variable[] + ): Har.Header[] { + const parser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + + if (Array.isArray(headers)) { + return headers.map((x: Postman.Header) => ({ + name: x.key, + value: parser.parse(x.value ?? '') + })); + } + + return headers.split('\n').map((pair: string) => { + const [name, value] = pair.split(':').map((x: string) => x.trim()); + + return { + name, + value: parser.parse(value ?? '') + }; + }); + } + + private convertUrl( + url: Postman.Url | string, + variables: Postman.Variable[] + ): string { + const subVariables = typeof url === 'string' ? [] : url.variable; + const envParser: VariableParser = this.parserFactory.createEnvVariableParser( + [...(subVariables ?? []), ...variables] + ); + + if (typeof url === 'string') { + return envParser.parse(url); + } + + const urlObject = this.prepareUrl(url); + + let value = envParser.parse(format(urlObject)); + + if (!/http(s)?:\/\//i.test(value)) { + value = this.DEFAULT_PROTOCOL + '://' + value; + } + + return value; + } + + private prepareUrl(url: Postman.Url): UrlObject { + const host: string | undefined = Array.isArray(url.host) + ? url.host.join('.') + : url.host; + + ok(host, 'Host is not defined.'); + + const protocol: string | undefined = url.protocol + ? url.protocol.replace(/:$/, ':') + : undefined; + + const urlParser: VariableParser = this.parserFactory.createUrlVariableParser( + url.variable + ); + + const pathname: string = Array.isArray(url.path) + ? url.path + .map((x: string | Postman.Variable) => + typeof x === 'string' + ? urlParser.parse(x) + : urlParser.parse(x.value ?? '') + ) + .join('/') + : url.path; + + const query = this.prepareQueries(url, urlParser); + + let auth = ''; + + if (url.auth) { + auth += url.auth.user ?? ''; + auth += ':' + (url.auth.password ?? ''); + } + + return { + auth, + query, + protocol, + host, + pathname: '/' + pathname.replace(/^\/+/, ''), + port: url.port, + hash: url.hash + }; + } + + private prepareQueries( + url: Postman.Url, + urlParser: VariableParser + ): ParsedUrlQuery | undefined { + return Array.isArray(url.query) + ? url.query.reduce((params: ParsedUrlQuery, x: Postman.QueryParam) => { + if (x.key) { + params[x.key] = !x.value ? urlParser.parse(x.key) : x.value; + } + + return params; + }, {}) + : undefined; + } + + private convertQuery( + url: Postman.Url | string, + variables: Postman.Variable[] + ): Har.QueryString[] { + let query: ParsedUrlQuery | undefined; + + const urlParser: VariableParser = this.parserFactory.createUrlVariableParser( + typeof url !== 'string' ? url.variable : [] + ); + + if (typeof url === 'string') { + query = parse(url, true).query; + } else { + query = this.prepareQueries(url, urlParser); + } + + if (!query) { + return []; + } + + const envParser: VariableParser = this.parserFactory.createEnvVariableParser( + variables + ); + + return Object.entries(query).map( + ([name, value]: [string, undefined | string | string[]]) => ({ + name, + value: envParser.parse( + (Array.isArray(value) ? value.join(',') : value) ?? '' + ) + }) + ); + } +} diff --git a/src/converter/index.ts b/src/converter/index.ts new file mode 100644 index 0000000..0e5fe6c --- /dev/null +++ b/src/converter/index.ts @@ -0,0 +1,2 @@ +export { Converter } from './Converter'; +export { DefaultConverter } from './DefaultConverter'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..beeabf6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,24 @@ +import { DefaultValidator } from './validator'; +import { DefaultConverter } from './converter'; +import { DefaultVariableParserFactory } from './parser'; +import Har from 'har-format'; +import { ok } from 'assert'; + +export const postman2har = async ( + collection: Postman.Collection, + options?: { + environment?: Record; + } +): Promise => { + ok(collection, `Please provide a valid Postman Collection.`); + + const validator: DefaultValidator = new DefaultValidator(); + const parserFactory: DefaultVariableParserFactory = new DefaultVariableParserFactory(); + const converter: DefaultConverter = new DefaultConverter( + validator, + parserFactory, + options ?? {} + ); + + return converter.convert(collection); +}; diff --git a/src/parser/BaseVariableParser.ts b/src/parser/BaseVariableParser.ts new file mode 100644 index 0000000..98afcad --- /dev/null +++ b/src/parser/BaseVariableParser.ts @@ -0,0 +1,29 @@ +import { VariableParser } from './VariableParser'; +import faker from 'faker'; + +export abstract class BaseVariableParser implements VariableParser { + protected constructor(private readonly variables: Postman.Variable[]) {} + + public abstract parse(value: string): string; + + public find(key: string): Postman.Variable | undefined { + return this.variables.find((x: Postman.Variable) => x.key === key); + } + + protected sample(variable?: Postman.Variable): string { + switch (variable?.type?.toLowerCase()) { + case 'string': + case 'text': + return faker.random.word(); + case 'number': + return String(faker.random.number({ min: 1, max: 99 })); + case 'any': + default: + return faker.random.arrayElement([ + faker.random.alphaNumeric(10), + String(faker.random.number({ min: 1, max: 99 })), + faker.random.uuid() + ]); + } + } +} diff --git a/src/parser/DefaultVariableParserFactory.ts b/src/parser/DefaultVariableParserFactory.ts new file mode 100644 index 0000000..3a44842 --- /dev/null +++ b/src/parser/DefaultVariableParserFactory.ts @@ -0,0 +1,18 @@ +import { VariableParserFactory } from './VariableParserFactory'; +import { VariableParser } from './VariableParser'; +import { EnvVariableParser } from './EnvVariableParser'; +import { UrlVariableParser } from './UrlVariableParser'; + +export class DefaultVariableParserFactory implements VariableParserFactory { + public createEnvVariableParser( + variables: Postman.Variable[] + ): VariableParser { + return new EnvVariableParser(variables); + } + + public createUrlVariableParser( + variables: Postman.Variable[] + ): VariableParser { + return new UrlVariableParser(variables); + } +} diff --git a/src/parser/EnvVariableParser.ts b/src/parser/EnvVariableParser.ts new file mode 100644 index 0000000..01bdfd1 --- /dev/null +++ b/src/parser/EnvVariableParser.ts @@ -0,0 +1,37 @@ +import { Replacer } from './Replacer'; +import { BaseVariableParser } from './BaseVariableParser'; + +export class EnvVariableParser extends BaseVariableParser { + private readonly REGEX_EXTRACT_VARS = /{{([^{}]*?)}}/g; + private readonly VARS_SUBREPLACE_LIMIT = 30; + + constructor(variables: Postman.Variable[]) { + super(variables); + } + + public parse(value: string): string { + let replacer: Replacer = new Replacer(value); + + do { + replacer = replacer.replace( + this.REGEX_EXTRACT_VARS, + (_match: string, token: string) => this.replace(token) + ); + } while ( + replacer.replacements && + replacer.substitutions < this.VARS_SUBREPLACE_LIMIT + ); + + return replacer.valueOf(); + } + + private replace(token: string): string { + const variable = this.find(token); + + if (!variable || !variable.value) { + return this.sample(); + } + + return variable.value; + } +} diff --git a/src/parser/Replacer.ts b/src/parser/Replacer.ts new file mode 100644 index 0000000..a5d8080 --- /dev/null +++ b/src/parser/Replacer.ts @@ -0,0 +1,51 @@ +export class Replacer { + private _substitutions = 0; + + get substitutions(): number { + return this._substitutions; + } + + private _replacements = 0; + + get replacements(): number { + return this._replacements; + } + + constructor(private value: string = '') {} + + public replace( + regex: RegExp, + strategy: string | ((...args: string[]) => string) + ): this { + let replacements = 0; + + this.value = this.value.replace( + regex, + typeof strategy === 'function' + ? (...args: string[]) => { + replacements += 1; + + return strategy(...args); + } + : () => { + replacements += 1; + + return strategy; + } + ); + + if (replacements) { + this._substitutions += 1; + } + + return this; + } + + public valueOf(): string { + return this.value; + } + + public toString(): string { + return this.value; + } +} diff --git a/src/parser/UrlVariableParser.ts b/src/parser/UrlVariableParser.ts new file mode 100644 index 0000000..902470a --- /dev/null +++ b/src/parser/UrlVariableParser.ts @@ -0,0 +1,34 @@ +import { BaseVariableParser } from './BaseVariableParser'; + +export class UrlVariableParser extends BaseVariableParser { + private readonly PATH_VARIABLE_IDENTIFIER = ':'; + + constructor(variables: Postman.Variable[]) { + super(variables); + } + + public parse(value: string): string { + if ( + value.startsWith(this.PATH_VARIABLE_IDENTIFIER) && + value !== this.PATH_VARIABLE_IDENTIFIER + ) { + const variable = this.find(this.normalizeKey(value)); + + if ( + variable && + typeof variable.value === 'string' && + variable.value !== 'schema type not provided' + ) { + return variable.value; + } + + return this.sample(variable); + } + + return value; + } + + private normalizeKey(token: string): string { + return token.replace(/^:/, ''); + } +} diff --git a/src/parser/VariableParser.ts b/src/parser/VariableParser.ts new file mode 100644 index 0000000..b26579e --- /dev/null +++ b/src/parser/VariableParser.ts @@ -0,0 +1,5 @@ +export interface VariableParser { + find(key: string): Postman.Variable | undefined; + + parse(value: string): string; +} diff --git a/src/parser/VariableParserFactory.ts b/src/parser/VariableParserFactory.ts new file mode 100644 index 0000000..f66a7f5 --- /dev/null +++ b/src/parser/VariableParserFactory.ts @@ -0,0 +1,7 @@ +import { VariableParser } from './VariableParser'; + +export interface VariableParserFactory { + createEnvVariableParser(variables: Postman.Variable[]): VariableParser; + + createUrlVariableParser(variables: Postman.Variable[]): VariableParser; +} diff --git a/src/parser/index.ts b/src/parser/index.ts new file mode 100644 index 0000000..1c0cc25 --- /dev/null +++ b/src/parser/index.ts @@ -0,0 +1,5 @@ +export { VariableParser } from './VariableParser'; +export { EnvVariableParser } from './EnvVariableParser'; +export { UrlVariableParser } from './UrlVariableParser'; +export { DefaultVariableParserFactory } from './DefaultVariableParserFactory'; +export { VariableParserFactory } from './VariableParserFactory'; diff --git a/src/types/postman.d.ts b/src/types/postman.d.ts new file mode 100644 index 0000000..41e98f8 --- /dev/null +++ b/src/types/postman.d.ts @@ -0,0 +1,208 @@ +declare namespace Postman { + export interface PropertyBaseDefinition { + description?: string | Description; + } + + export interface Property extends PropertyBaseDefinition { + id?: string; + name?: string; + disabled?: boolean; + } + + export interface Certificate extends Property { + matches?: string[] | UrlMatchPattern[]; + key?: { src?: string } | string; + cert?: { src?: string } | string; + passphrase?: string; + } + + export interface ItemGroup extends Property, VariableScope { + item: (Item | ItemGroup)[]; + auth?: RequestAuth; + event?: Event[]; + } + + export interface Collection extends ItemGroup { + info: { + schema: string; + name?: string; + description?: Description | string; + version?: Version | string; + }; + } + + export interface Cookie { + key?: string; + value?: string; + expires?: string; + maxAge?: number; + domain: string; + path: string; + secure?: boolean; + httpOnly?: boolean; + hostOnly?: boolean; + session?: boolean; + extensions?: { key: string; value: string }[]; + } + + export interface Description { + content: string; + type?: string; + } + + export interface Event extends Property { + listen?: string; + script: string | string[] | Script; + } + + export interface FormParam extends Property { + key: string; + value?: string; + contentType?: string; + src?: string[] | string; + } + + export interface Header extends Property { + key: string; + value?: string; + system?: boolean; + } + + export interface Item extends Property, VariableScope { + request?: Request; + response?: Response[]; + event?: Event[]; + } + + export interface ProxyConfig extends Property { + match?: string | { pattern: string } | UrlMatchPattern; + host?: string; + port?: number; + tunnel?: boolean; + } + + export interface QueryParam extends Property { + key: string; + value?: string; + system?: boolean; + } + + export interface Request extends Property { + url: string | Url; + method?: + | ( + | 'GET' + | 'PUT' + | 'POST' + | 'PATCH' + | 'DELETE' + | 'COPY' + | 'HEAD' + | 'OPTIONS' + | 'LINK' + | 'UNLINK' + | 'PURGE' + | 'LOCK' + | 'UNLOCK' + | 'PROPFIND' + | 'VIEW' + ) + | string; + header?: Header[] | string; + body?: RequestBody; + auth?: RequestAuth; + proxy?: ProxyConfig; + certificate?: Certificate; + } + + export interface RequestAuth extends Property { + type: + | 'apikey' + | 'awsv4' + | 'basic' + | 'bearer' + | 'digest' + | 'edgegrid' + | 'hawk' + | 'noauth' + | 'oauth1' + | 'oauth2' + | 'ntlm'; + noauth?: Variable[]; + apikey?: Variable[]; + awsv4?: Variable[]; + basic?: Variable[]; + bearer?: Variable[]; + digest?: Variable[]; + edgegrid?: Variable[]; + hawk?: Variable[]; + ntlm?: Variable[]; + oauth1?: Variable[]; + oauth2?: Variable[]; + } + + export interface RequestBody extends PropertyBaseDefinition { + mode?: 'raw' | 'urlencoded' | 'formdata' | 'file' | 'graphql'; + raw?: string; + graphql?: { + [k: string]: any; + }; + urlencoded?: QueryParam[] | string; + file?: string | { src: string | null; content?: string }; + formdata?: FormParam[]; + options?: { + [key: string]: { + language: string; + }; + }; + } + + export interface Response extends Property { + body?: string; + code: number; + cookie: Cookie[]; + header: Header[]; + originalRequest?: Request; + responseTime: number; + status: string; + responseSize?: number; + } + + export interface Script extends Property { + type?: string; + src?: Url; + exec?: string[] | string; + } + + export interface Url extends PropertyBaseDefinition, VariableScope { + raw?: string; + auth?: { user?: string; password?: string }; + hash?: string; + host?: string | string[]; + path: string | (string | { type?: string; value?: string })[]; + port?: string; + protocol?: string; + query?: QueryParam[]; + } + + export interface VariableScope { + variable: Variable[]; + } + + export interface UrlMatchPattern { + pattern?: string; + } + + export interface Variable extends Property { + value?: any; + type?: string; + key?: string; + } + + export interface Version extends PropertyBaseDefinition { + identifier?: string; + major: number; + minor: number; + patch: number; + } +} diff --git a/src/validator/DefaultValidator.ts b/src/validator/DefaultValidator.ts new file mode 100644 index 0000000..c51de8e --- /dev/null +++ b/src/validator/DefaultValidator.ts @@ -0,0 +1,37 @@ +import { Validator } from './Validator'; +import semver from 'semver'; +import { ok } from 'assert'; + +export class DefaultValidator implements Validator { + private readonly ALLOWED_SCHEMAS: ReadonlyArray = [ + 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json', + 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + ]; + private readonly MIN_ALLOWED_VERSION = '2.0.0'; + + public async verify(collection: Postman.Collection): Promise { + ok(collection, 'Postman collection is not provided.'); + ok(collection.info, '"info" section is missed in the collection.'); + + const versionMismatch: Error = new Error( + 'Postman v1 collections are not supported. If you are using an older format, convert it to v2 and try again.' + ); + + if (collection.info.version) { + const { version: versionObject } = collection.info; + + const version: string = + typeof versionObject === 'string' + ? versionObject + : `${versionObject.major}.${versionObject.minor}.${versionObject.patch}`; + + if (!semver.gte(version, this.MIN_ALLOWED_VERSION)) { + throw versionMismatch; + } + } + + if (!this.ALLOWED_SCHEMAS.includes(collection.info.schema.trim())) { + throw versionMismatch; + } + } +} diff --git a/src/validator/Validator.ts b/src/validator/Validator.ts new file mode 100644 index 0000000..230dbc2 --- /dev/null +++ b/src/validator/Validator.ts @@ -0,0 +1,3 @@ +export interface Validator { + verify(collection: Postman.Collection): Promise; +} diff --git a/src/validator/index.ts b/src/validator/index.ts new file mode 100644 index 0000000..ef503a7 --- /dev/null +++ b/src/validator/index.ts @@ -0,0 +1,2 @@ +export { Validator } from './Validator'; +export { DefaultValidator } from './DefaultValidator'; diff --git a/tsconfig.json b/tsconfig.json index e4356f0..32f23d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,9 @@ "compilerOptions": { "baseUrl": ".", "outDir": "dist", - "target": "es2018", + "target": "es2019", "lib": [ - "es2018" + "es2020" ], "module": "commonjs", "esModuleInterop": true, @@ -17,7 +17,6 @@ "downlevelIteration": true, "strict": true, "noImplicitAny": true, - "strictNullChecks": false, "noUnusedLocals": true, "noUnusedParameters": true, "suppressImplicitAnyIndexErrors": true, @@ -27,7 +26,7 @@ "experimentalDecorators": true, "typeRoots": [ "node_modules/@types", - "typings" + "@types" ], "skipLibCheck": true, "newLine": "LF",