diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/auth.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/auth.ts new file mode 100644 index 0000000000..2a661a9df7 --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/auth.ts @@ -0,0 +1,336 @@ +import { Property } from './base'; +import { Variable, VariableList } from './variables'; + +const AuthTypes = new Set([ + 'noauth', + 'basic', + 'bearer', + 'jwt', + 'digest', + 'oauth1', + 'oauth2', + 'hawk', + 'awsv4', + 'ntlm', + 'apikey', + 'edgegrid', + 'asap', +]); + +export interface AuthOption { + type: string; + key: string; + value: string; +} + +// export interface AuthBasic { +// password: string; +// username: string; +// id: string; +// } + +// export interface AuthBearer { +// token: string; +// id: string; +// } + +// export interface AuthJWT { +// secret: string; +// algorithm: string; +// isSecretBase64Encoded: boolean; +// payload: string; // e.g. "{}" +// addTokenTo: string; +// headerPrefix: string; +// queryParamKey: string; +// header: string; // e.g. "{}" +// id: string; +// } + +// export interface AuthDigest { +// opaque: string; +// clientNonce: string; +// nonceCount: string; +// qop: string; +// nonce: string; +// realm: string; +// password: string; +// username: string; +// algorithm: string; +// id: string; +// } + +// export interface AuthOAuth1 { +// addEmptyParamsToSign: boolean; +// includeBodyHash: boolean; +// realm: string; +// nonce: string; +// timestamp: string; +// verifier: string; +// callback: string; +// tokenSecret: string; +// token: string; +// consumerSecret: string; +// consumerKey: string; +// signatureMethod: string; // "HMAC-SHA1" +// version: string; +// addParamsToHeader: string; +// id: string; +// } + +// export interface OAuth2Param { +// key: string; +// value: string; +// enabled: boolean; +// send_as: string; // it follows exising naming +// } + +// export interface AuthOAuth2 { +// accessToken: string; +// refreshRequestParams: OAuth2Param[]; +// tokenRequestParams: OAuth2Param[]; +// authRequestParams: OAuth2Param[]; +// refreshTokenUrl: string; +// state: string; +// scope: string; +// clientSecret: string; +// clientId: string; +// tokenName: string; +// addTokenTo: string; +// id: string; +// } + +// export interface AuthHAWK { +// includePayloadHash: boolean; +// timestamp: string; +// delegation: string; +// app: string; +// extraData: string; +// nonce: string; +// user: string; +// authKey: string; +// authId: string; +// algorithm: string; +// id: string; +// } + +// export interface AuthAWSV4 { +// sessionToken: string; +// service: string; +// region: string; +// secretKey: string; +// accessKey: string; +// id: string; +// } + +// export interface AuthNTLM { +// workstation: string; +// domain: string; +// password: string; +// username: string; +// id: string; +// } + +// export interface AuthAPIKey { +// key: string; +// value: string; +// id: string; +// } + +// export interface AuthEdgegrid { +// headersToSign: string; +// baseURL: string; +// timestamp: string; +// nonce: string; +// clientSecret: string; +// clientToken: string; +// accessToken: string; +// id: string; +// } + +// export interface AuthASAP { +// exp: string; // expiry +// claims: string; // e.g., { "additional claim": "claim value" } +// sub: string; // subject +// privateKey: string; // private key +// kid: string; // key id +// aud: string; // audience +// iss: string; // issuer +// alg: string; // e.g., RS256 +// id: string; +// } + +// function AuthMethodToParams(authMethod: AuthNoAuth | AuthBasic | AuthBasic | AuthBearer | AuthJWT | AuthDigest | AuthOAuth1 | AuthOAuth2 | AuthHAWK | AuthAWSV4 | AuthNTLM | AuthAPIKey | AuthEdgegrid | AuthASAP) { +// return Object.entries(authMethod). +// map(entry => ({ +// type: 'any', +// key: entry[0], +// value: entry[1], +// })); +// } + +export interface AuthOptions { + type: string; + basic?: AuthOption[]; + bearer?: AuthOption[]; + jwt?: AuthOption[]; + digest?: AuthOption[]; + oauth1?: AuthOption[]; + oauth2?: AuthOption[]; + hawk?: AuthOption[]; + awsv4?: AuthOption[]; + ntlm?: AuthOption[]; + apikey?: AuthOption[]; + edgegrid?: AuthOption[]; + asap?: AuthOption[]; +} + +function rawOptionsToVariables(options: VariableList | Variable[] | object, targetType?: string): VariableList[] { + if (VariableList.isVariableList(options)) { + return [options as VariableList]; + } else if ('type' in options) { // object + const optsObj = options as AuthOptions; + const optsVarLists = Object.entries(optsObj) + .filter(optsObjEntry => optsObjEntry[0] === targetType) + .map(optsEntry => { + return new VariableList( + undefined, + optsEntry.map(opt => new Variable({ + key: opt.key, + value: opt.value, + type: opt.type, + })), + ); + }); + + return optsVarLists; + } else if ('length' in options) { // array + return [new VariableList(undefined, options)]; + } + + throw Error('options is not valid: it must be VariableList | Variable[] | object'); +} + +export class RequestAuth extends Property { + private type: string; + private authOptions: Map> = new Map(); + + constructor(options: AuthOptions, parent?: Property) { + super(); + + if (!RequestAuth.isValidType(options.type)) { + throw Error(`invalid auth type ${options.type}`); + } + this.type = options.type; + const optsObj = options as AuthOptions; + Object.entries(optsObj) + .filter(optsObjEntry => optsObjEntry[0] !== 'type') + .map(optsEntry => { + return { + type: optsEntry[0], + options: new VariableList( + undefined, + optsEntry.map(opt => new Variable({ + key: opt.key, + value: opt.value, + type: opt.type, + })), + ), + }; + }) + .forEach(authOpts => { + this.authOptions.set(authOpts.type, authOpts.options); + }); + + this._parent = parent; + } + + static isValidType(authType: string) { + return AuthTypes.has(authType); + } + + clear(type: string) { + if (RequestAuth.isValidType(type)) { + this.authOptions.delete(type); + } + } + + parameters() { + return this.authOptions.get(this.type); + } + + toJSON() { + const obj: AuthOptions = { type: this.type }; + const authOption = this.authOptions.get(this.type); + if (!authOption) { + return obj; + } + + const authOptionJSON = authOption.map(optValue => optValue.toJSON(), {}); + + switch (this.type) { + case 'basic': + obj.basic = authOptionJSON; + break; + case 'bearer': + obj.bearer = authOptionJSON; + break; + case 'jwt': + obj.jwt = authOptionJSON; + break; + case 'digest': + obj.digest = authOptionJSON; + break; + case 'oauth1': + obj.oauth1 = authOptionJSON; + break; + case 'oauth2': + obj.oauth2 = authOptionJSON; + break; + case 'hawk': + obj.hawk = authOptionJSON; + break; + case 'awsv4': + obj.awsv4 = authOptionJSON; + break; + case 'ntlm': + obj.ntlm = authOptionJSON; + break; + case 'apikey': + obj.apikey = authOptionJSON; + break; + case 'edgegrid': + obj.edgegrid = authOptionJSON; + break; + case 'asap': + obj.asap = authOptionJSON; + break; + default: // noauth, no op + } + + return obj; + } + + update(options: VariableList | Variable[] | object, type?: string) { + const currentType = type ? type : this.type; + const authOpts = rawOptionsToVariables(options, currentType); + if (authOpts.length > 0) { + this.authOptions.set(currentType, authOpts[0]); + } else { + throw Error('no valid RequestAuth options is found'); + } + } + + use(type: string, options: VariableList | Variable[] | object) { + if (!RequestAuth.isValidType(type)) { + throw Error(`invalid type (${type}), it must be noauth | basic | bearer | jwt | digest | oauth1 | oauth2 | hawk | awsv4 | ntlm | apikey | edgegrid | asap.`); + } + + const authOpts = rawOptionsToVariables(options, type); + if (authOpts.length > 0) { + this.type = type; + this.authOptions.set(type, authOpts[0]); + } else { + throw Error('no valid RequestAuth options is found'); + } + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/base.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/base.ts new file mode 100644 index 0000000000..5d2f1d223c --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/base.ts @@ -0,0 +1,388 @@ +import clone from 'clone'; +import equal from 'deep-equal'; + +export interface JSONer { + toJSON: () => object; +} + +export class PropertyBase { + public kind = 'PropertyBase'; + protected description: string; + protected _parent: PropertyBase | undefined = undefined; + + constructor(def: { description: string }) { + this.description = def.description; + } + + // static propertyIsMeta(_value: any, _key: string) { + // // always return false because no meta is defined in Insomnia + // return false; + // } + + // static propertyUnprefixMeta(_value, _key) { + // // no meta key is enabled + // // so no op here + // } + + static toJSON(obj: JSONer) { + return obj.toJSON(); + } + + meta() { + return {}; + }; + + parent() { + return this._parent; + } + + forEachParent(_options: { withRoot?: boolean }, iterator: (obj: PropertyBase) => boolean) { + const currentParent = this.parent(); + if (!currentParent) { + return; + } + + const queue: PropertyBase[] = [currentParent]; + const parents: PropertyBase[] = []; + + while (queue.length > 0) { + const ancester = queue.shift(); + if (!ancester) { + continue; + } + + // TODO: check options + const cloned = clone(ancester); + const keepIterating = iterator(cloned); + parents.push(cloned); + if (!keepIterating) { + break; + } + + const olderAncester = ancester.parent(); + if (olderAncester) { + queue.push(olderAncester); + } + } + + return parents; + } + + findInParents(property: string, customizer?: (ancester: PropertyBase) => boolean): PropertyBase | undefined { + const currentParent = this.parent(); + if (!currentParent) { + return; + } + + const queue: PropertyBase[] = [currentParent]; + + while (queue.length > 0) { + const ancester = queue.shift(); + if (!ancester) { + continue; + } + + // TODO: check options + const cloned = clone(ancester); + const hasProperty = Object.keys(cloned.meta()).includes(property); + if (!hasProperty) { + // keep traversing until parent has the property + // no op + } else { + if (customizer) { + if (customizer(cloned)) { + // continue until customizer returns a truthy value + return cloned; + } + } else { + // customizer is not specified, stop at the first parent that contains the property + return cloned; + } + } + + const olderAncester = ancester.parent(); + if (olderAncester) { + queue.push(olderAncester); + } + } + + return undefined; + } + + toJSON() { + const entriesToExport = Object + .entries(this) + .filter((kv: [string, any]) => + typeof kv[1] !== 'function' && typeof kv[1] !== 'undefined' + ); + + return Object.fromEntries(entriesToExport); + } + + toObject() { + return this.toJSON(); + } + + toString() { + return JSON.stringify(this.toJSON()); + } +} + +export class Property extends PropertyBase { + id?: string; + name?: string; + disabled?: boolean; + info?: object; + + constructor(def?: { + id?: string; + name?: string; + disabled?: boolean; + info?: object; + }) { + super({ description: 'Property' }); + this.id = def?.id || ''; + this.name = def?.name || ''; + this.disabled = def?.disabled || false; + this.info = def?.info || {}; + } + + // static replaceSubstitutions(_str: string, _variables: object): string { + // // TODO: unsupported + // return ''; + // } + + // static replaceSubstitutionsIn(obj: string, variables: object): object { + // // TODO: unsupported + // return {}; + // } + + describe(content: string, typeName: string) { + this.kind = typeName; + this.description = content; + } +} + +export class PropertyList { + kind: string = 'PropertyList'; + protected _parent: PropertyList | undefined = undefined; + protected list: T[] = []; + + constructor( + // public readonly typeClass: { new(...arg: any): T }, + // public readonly parent: string, + populate: T[], + ) { + this.list = populate; + } + + static isPropertyList(obj: object) { + return 'kind' in obj && obj.kind === 'PropertyList'; + } + + add(item: T) { + this.list.push(item); + } + + all() { + return new Map( + this.list.map( + pp => [pp.id, pp.toJSON()] + ), + ); + } + + append(item: T) { + // it doesn't move item to the end of list for avoiding side effect + this.add(item); + } + + assimilate(source: T[] | PropertyList, prune?: boolean) { + // it doesn't update values from a source list + if (prune) { + this.clear(); + } + if ('list' in source) { // it is PropertyList + this.list.push(...source.list); + } else { + this.list.push(...source); + } + } + + clear() { + this.list = []; + } + + count() { + return this.list.length; + } + + each(iterator: (item: T) => void, context: object) { + interface Iterator { + context?: object; + (item: T): void; + } + const it: Iterator = iterator; + it.context = context; + + this.list.forEach(it); + } + + // TODO: unsupported + // eachParent(iterator, contextopt) {} + + filter(rule: (item: T) => boolean, context: object) { + interface Iterator { + context?: object; + (item: T): boolean; + } + const it: Iterator = rule; + it.context = context; + + return this.list.filter(it); + } + + // TODO: support returning {Item|ItemGroup} + find(rule: (item: T) => boolean, context?: object) { + interface Finder { + context?: object; + (item: T): boolean; + } + const finder: Finder = rule; + finder.context = context; + + return this.list.find(finder); + } + + // it does not return underlying type of the item because they are not supported + get(key: string) { + return this.one(key); + } + + // TODO: value is not used as its usage is unknown + // eslint-disable-next-line @typescript-eslint/no-unused-vars + has(item: T, _value: any) { + return this.indexOf(item) >= 0; + } + + idx(index: number) { + if (index <= this.list.length - 1) { + return this.list[index]; + } + return undefined; + } + + indexOf(item: string | T) { + for (let i = 0; i < this.list.length; i++) { + if (typeof item === 'string') { + if (item === this.list[i].id) { + return i; + } + } else { + if (equal(item, this.list[i])) { + return i; + } + } + } + return -1; + } + + insert(item: T, before?: number) { + if (before && before <= this.list.length - 1) { + this.list = [...this.list.slice(0, before), item, ...this.list.slice(before)]; + } else { + this.append(item); + } + } + + insertAfter(item: T, after?: number) { + if (after && after <= this.list.length - 1) { + this.list = [...this.list.slice(0, after + 1), item, ...this.list.slice(after + 1)]; + } else { + this.append(item); + } + } + + map(iterator: (item: T) => any, context: object) { + interface Iterator { + context?: object; + (item: T): any; + } + const it: Iterator = iterator; + it.context = context; + + return this.list.map(it); + } + + one(id: string) { + for (let i = this.list.length - 1; i >= 0; i--) { + if (this.list[i].id === id) { + return this.list[i]; + } + } + + return undefined; + } + + populate(items: T[]) { + this.list = [...this.list, ...items]; + } + + prepend(item: T) { + this.list = [item, ...this.list]; + } + + reduce(iterator: ((acc: any, item: T) => any), accumulator: any, context: object) { + interface Iterator { + context?: object; + (acc: any, item: T): any; + } + const it: Iterator = iterator; + it.context = context; + + this.list.reduce(it, accumulator); + } + + remove(predicate: T | ((item: T) => boolean), context: object) { + if (typeof predicate === 'function') { + this.list = this.filter(predicate, context); + } else { + this.list = this.filter(item => equal(predicate, item), context); + } + } + + repopulate(items: T[]) { + this.clear(); + this.populate(items); + } + + // unsupportd as _postman_propertyIndexKey is not supported + // toObject(excludeDisabled?: boolean, caseSensitive?: boolean, multiValue?: boolean, sanitizeKeys?: boolean) { + // const itemObjects = this.list + // .filter(item => { + // if (excludeDisabled) { + // return !item.disabled; + // } + // return true; + // }) + // .map(item => { + // return item.toJSON(); + // }); + // } + + toString() { + const itemStrs = this.list.map(item => item.toString()); + return `[${itemStrs.join(',')}]`; + } + + upsert(item: T): boolean { + const itemIdx = this.indexOf(item); + if (itemIdx >= 0) { + this.list = [...this.list.splice(0, itemIdx), item, ...this.list.splice(itemIdx + 1)]; + return false; + } + + this.add(item); + return true; + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/certificates.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/certificates.ts new file mode 100644 index 0000000000..7e8d50cf2f --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/certificates.ts @@ -0,0 +1,58 @@ +import { Property } from './base'; +import { UrlMatchPattern, UrlMatchPatternList } from './urls'; + +export interface CertificateOptions { + name?: string; + matches?: string[]; + key?: object; + cert?: object; + passphrase?: string; + pfx?: object; // PFX or PKCS12 Certificate +} + +export class Certificate extends Property { + name?: string; + matches?: UrlMatchPatternList; + key?: object; + cert?: object; + passphrase?: string; + pfx?: object; // PFX or PKCS12 Certificate + + constructor(options: CertificateOptions) { + super(); + this.kind = 'Certificate'; + this.name = options.name; + this.matches = new UrlMatchPatternList( + undefined, + options.matches ? + options.matches.map(matchStr => new UrlMatchPattern(matchStr)) : + [], + ); + this.key = options.key; + this.cert = options.cert; + this.passphrase = options.passphrase; + this.pfx = options.pfx; + } + + static isCertificate(obj: object) { + return 'kind' in obj && obj.kind === 'Certificate'; + } + + canApplyTo(url: string) { + return this.matches ? this.matches.test(url) : false; + } + + update(options: CertificateOptions) { + this.name = options.name; + this.matches = new UrlMatchPatternList( + undefined, + options.matches ? + options.matches.map(matchStr => new UrlMatchPattern(matchStr)) : + [], + ); + this.key = options.key; + this.cert = options.cert; + this.passphrase = options.passphrase; + this.pfx = options.pfx; + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/cookies.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/cookies.ts new file mode 100644 index 0000000000..1f47df0dea --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/cookies.ts @@ -0,0 +1,153 @@ +import { Property, PropertyList } from './base'; + +export interface CookieOptions { + key: string; + value: string; + expires?: Date | string; + maxAge?: Number; + domain?: string; + path?: string; + secure?: Boolean; + httpOnly?: Boolean; + hostOnly?: Boolean; + session?: Boolean; + extensions?: { key: string; value: string }[]; +} + +export class Cookie extends Property { + private def: object; + + constructor(cookieDef: CookieOptions | string) { + super(); + this.kind = 'Cookie'; + this.description = 'Cookie'; + + if (typeof cookieDef === 'string') { + this.def = Cookie.parse(cookieDef); + } else { + this.def = cookieDef; + } + } + + static isCookie(obj: Property) { + return obj.kind === 'Cookie'; + } + + static parse(cookieStr: string) { + const parts = cookieStr.split(';'); + + const def: CookieOptions = { key: '', value: '' }; + const extensions: { key: string; value: string }[] = []; + + parts.forEach((part, i) => { + const kvParts = part.split('='); + const key = kvParts[0]; + + if (i === 0) { + const value = kvParts.length > 1 ? kvParts[1] : ''; + def.key, def.value = key, value; + } else { + switch (key) { + case 'Expires': + // TODO: it should be timestamp + const expireVal = kvParts.length > 1 ? kvParts[1] : '0'; + def.expires = expireVal; + break; + case 'Max-Age': + let maxAgeVal = 0; + if (kvParts.length > 1) { + maxAgeVal = parseInt(kvParts[1], 10); + } + def.maxAge = maxAgeVal; + break; + case 'Domain': + const domainVal = kvParts.length > 1 ? kvParts[1] : ''; + def.domain = domainVal; + break; + case 'Path': + const pathVal = kvParts.length > 1 ? kvParts[1] : ''; + def.path = pathVal; + break; + case 'Secure': + def.secure = true; + break; + case 'HttpOnly': + def.httpOnly = true; + break; + case 'HostOnly': + def.hostOnly = true; + break; + case 'Session': + def.session = true; + break; + default: + const value = kvParts.length > 1 ? kvParts[1] : ''; + extensions.push({ key, value }); + def.extensions = extensions; + } + } + }); + + return def; + } + + static stringify(cookie: Cookie) { + return cookie.toString(); + } + + static unparseSingle(cookie: Cookie) { + return cookie.toString(); + } + + // TODO: support PropertyList + static unparse(cookies: Cookie[]) { + const cookieStrs = cookies.map(cookie => cookie.toString()); + return cookieStrs.join(';'); + } + + toString = () => { + // Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + const cookieDef = this.def as CookieOptions; + const kvPair = `${cookieDef.key}=${cookieDef.value};`; + const expires = cookieDef.expires ? `Expires=${cookieDef.expires?.toString()};` : ''; + const maxAge = cookieDef.maxAge ? `Max-Age=${cookieDef.maxAge};` : ''; + const domain = cookieDef.domain ? `Domain=${cookieDef.domain};` : ''; + const path = cookieDef.path ? `Path=${cookieDef.path};` : ''; + const secure = cookieDef.secure ? 'Secure;' : ''; + const httpOnly = cookieDef.httpOnly ? 'HttpOnly;' : ''; + // TODO: SameSite, Partitioned is not suported + + const hostOnly = cookieDef.hostOnly ? 'HostOnly;' : ''; + const session = cookieDef.session ? 'Session;' : ''; + + // TODO: extension key may be conflict with pre-defined keys + const extensions = cookieDef.extensions ? + cookieDef.extensions + .map((kv: { key: string; value: string }) => `${kv.key}=${kv.value}`) + .join(';') : ''; // the last field doesn't have ';' + + return `${kvPair} ${expires} ${maxAge} ${domain} ${path} ${secure} ${httpOnly} ${hostOnly} ${session} ${extensions}`; + }; + + valueOf = () => { + return (this.def as CookieOptions).value; + }; +} + +export class CookieList extends PropertyList { + kind: string = 'CookieList'; + cookies: Cookie[]; + + constructor(parent: CookieList | undefined, cookies: Cookie[]) { + super( + // Cookie, parent.toString() + cookies + ); + this._parent = parent; + this.cookies = cookies; + } + + static isCookieList(obj: object) { + return 'kind' in obj && obj.kind === 'CookieList'; + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/headers.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/headers.ts new file mode 100644 index 0000000000..b677af6ca9 --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/headers.ts @@ -0,0 +1,106 @@ +import { Property, PropertyList } from './base'; + +export interface HeaderOptions { + id?: string; + name?: string; + type?: string; + disabled?: boolean; + key: string; + value: string; +} + +export class Header extends Property { + kind: string = 'Header'; + type: string = ''; + key: string; + value: string; + + constructor( + opts: HeaderOptions | string, + name?: string, // if it is defined, it overrides 'key' (not 'name') + ) { + super(); + + if (typeof opts === 'string') { + const obj = Header.parseSingle(opts); + this.key = obj.key; + this.value = obj.value; + } else { + this.id = opts.id ? opts.id : ''; + this.key = opts.key ? opts.key : ''; + this.name = name ? name : (opts.name ? opts.name : ''); + this.value = opts.value ? opts.value : ''; + this.type = opts.type ? opts.type : ''; + this.disabled = opts ? opts.disabled : false; + } + } + + static create(input?: { key: string; value: string } | string, name?: string): Header { + return new Header(input || { key: '', value: '' }, name); + } + + static isHeader(obj: object) { + return 'kind' in obj && obj.kind === 'Header'; + } + + // example: 'Content-Type: application/json\nUser-Agent: MyClientLibrary/2.0\n' + static parse(headerString: string): { key: string; value: string }[] { + return headerString + .split('\n') + .map(kvPart => Header.parseSingle(kvPart)); + } + + static parseSingle(headerStr: string): { key: string; value: string } { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers + // the first colon is the separator + const separatorPos = headerStr.indexOf(':'); + const key = headerStr.slice(0, separatorPos); + const value = headerStr.slice(separatorPos + 1); + + return { key, value }; + } + + static unparse(headers: { key: string; value: string }[] | PropertyList
, separator?: string): string { + const headerStrs = headers.map( + header => this.unparseSingle(header), {} + ); + + return headerStrs.join(separator || '\n'); + } + + static unparseSingle(header: { key: string; value: string } | Header): string { + // both PropertyList and object contains 'key' and 'value' + return `${header.key}: ${header.value}`; + } + + update(newHeader: { key: string; value: string }) { + this.key = newHeader.key; + this.value = newHeader.value; + } + + valueOf() { + return this.value; + } +} + +export class HeaderList extends PropertyList { + constructor(parent: PropertyList | undefined, populate: T[]) { + super(populate); + this._parent = parent; + } + + static isHeaderList(obj: any) { + return 'kind' in obj && obj.kind === 'HeaderList'; + } + + // unsupported + // eachParent(iterator, contextopt) + // toObject(excludeDisabledopt, nullable, caseSensitiveopt, nullable, multiValueopt, nullable, sanitizeKeysopt) → {Object} + + contentSize(): number { + return this.list + .map(header => header.toString()) + .map(headerStr => headerStr.length) // TODO: handle special characters + .reduce((totalSize, headerSize) => totalSize + headerSize, 0); + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/intepolator.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/intepolator.ts new file mode 100644 index 0000000000..7d665aaaf6 --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/intepolator.ts @@ -0,0 +1,34 @@ +import { configure, type ConfigureOptions, type Environment as NunjuncksEnv } from 'nunjucks'; + +class Intepolator { + private engine: NunjuncksEnv; + + constructor(config: ConfigureOptions) { + this.engine = configure(config); + } + + render = (template: string, context: object) => { + // TODO: handle timeout + // TODO: support plugin? + return this.engine.renderString(template, context); + }; +} + +const intepolator = new Intepolator({ + autoescape: false, + // Don't escape HTML + throwOnUndefined: true, + // Strict mode + tags: { + blockStart: '{%', + blockEnd: '%}', + variableStart: '{{', + variableEnd: '}}', + commentStart: '{#', + commentEnd: '#}', + }, +}); + +export function getIntepolator() { + return intepolator; +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/proxy-configs.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/proxy-configs.ts new file mode 100644 index 0000000000..78e4bd8c5c --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/proxy-configs.ts @@ -0,0 +1,168 @@ +import { Property, PropertyList } from './base'; + +export interface ProxyConfigOptions { + match: string; + host: string; + port: number; + tunnel: boolean; + disabled?: boolean; + authenticate: boolean; + username: string; + password: string; +} + +export class ProxyConfig extends Property { + kind: string = 'ProxyConfig'; + type: string; + + host: string; + match: string; + port: number; + tunnel: boolean; + authenticate: boolean; + username: string; + password: string; + + static authenticate: boolean; + // static bypass: UrlMatchPatternList; + static host: string; + static match: string; + static password: string; + static port: number; + static tunnel: boolean; // unsupported + static username: string; + + static { + ProxyConfig.authenticate = false; + // ProxyConfig.bypass: UrlMatchPatternList; + ProxyConfig.host = ''; + ProxyConfig.match = ''; + ProxyConfig.password = ''; + ProxyConfig.port = 0; + ProxyConfig.tunnel = false; + ProxyConfig.username = ''; + } + + constructor(def: { + id?: string; + name?: string; + type?: string; + + match: string; + host: string; + port: number; + tunnel: boolean; + disabled?: boolean; + authenticate: boolean; + username: string; + password: string; + + }) { + super(); + + this.id = def.id ? def.id : ''; + this.name = def.name ? def.name : ''; + this.type = def.type ? def.type : ''; + this.disabled = def.disabled ? def.disabled : false; + + this.host = def.host; + this.match = def.match; + this.port = def.port; + this.tunnel = def.tunnel; + this.authenticate = def.authenticate; + this.username = def.username; + this.password = def.password; + } + + static isProxyConfig(obj: object) { + return 'kind' in obj && obj.kind === 'ProxyConfig'; + } + + // TODO: should not read from match? + getProtocols(): string[] { + // match field example: 'http+https://example.com/*' + const protoSeparator = this.match.indexOf('://'); + if (protoSeparator <= 0 || protoSeparator >= this.match.length) { + return []; // invalid match value + } + + return this.match + .slice(0, protoSeparator) + .split('+'); + } + + getProxyUrl(): string { + const protos = this.getProtocols(); + // TODO: how to pick up a protocol? + if (protos.length === 0) { + return ''; + } + + // http://proxy_username:proxy_password@proxy.com:8080 + if (this.authenticate) { + return `protos[0]://${this.username}:${this.password}@${this.host}:${this.port}`; + } + return `protos[0]://${this.host}:${this.port}`; + } + + // TODO: unsupported yet + // test(urlStropt) + + update(options: { + host: string; + match: string; + port: number; + tunnel: boolean; + authenticate: boolean; + username: string; + password: string; + }) { + this.host = options.host; + this.match = options.match; + this.port = options.port; + this.tunnel = options.tunnel; + this.authenticate = options.authenticate; + this.username = options.username; + this.password = options.password; + } + + updateProtocols(protocols: string[]) { + const protoSeparator = this.match.indexOf('://'); + if (protoSeparator <= 0 || protoSeparator >= this.match.length) { + return; // invalid match value + } + + this.match = protocols.join('+') + this.match.slice(protoSeparator); + } +} + +// myProxyConfig = new ProxyConfigList({}, [ +// {match: 'https://example.com/*', host: 'proxy.com', port: 8080, tunnel: true}, +// {match: 'http+https://example2.com/*', host: 'proxy2.com'}, +// ]); + +export class ProxyConfigList extends PropertyList { + constructor(parent: PropertyList | undefined, populate: T[]) { + super(populate); + this._parent = parent; + } + + static isProxyConfigList(obj: any) { + return 'kind' in obj && obj.kind === 'ProxyConfigList'; + } + + // TODO: need support URL at first + // resolve(url?: URL) { + // return { + // host: string; + // match: string; + // port: number; + // tunnel: boolean; + // authenticate: boolean; + // username: string; + // password: string; + // } + // } + + // toObject(excludeDisabledopt, nullable, caseSensitiveopt, nullable, multiValueopt, nullable, sanitizeKeysopt) → {Object} +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/req-resp.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/req-resp.ts new file mode 100644 index 0000000000..f00a23f772 --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/req-resp.ts @@ -0,0 +1,574 @@ +import { RESPONSE_CODE_REASONS } from '../../../common/constants'; +import { AuthOptions, RequestAuth } from './auth'; +import { Property, PropertyBase, PropertyList } from './base'; +import { CertificateOptions } from './certificates'; +import { Certificate } from './certificates'; +import { Cookie, CookieList, CookieOptions } from './cookies'; +import { HeaderOptions } from './headers'; +import { Header, HeaderList } from './headers'; +import { ProxyConfig, ProxyConfigOptions } from './proxy-configs'; +import { QueryParam, Url } from './urls'; +import { Variable, VariableList } from './variables'; + +// export type RequestBodyMode = +// file string +// formdata string +// graphql string +// raw string +// urlencoded string + +export type RequestBodyMode = undefined | 'formdata' | 'urlencoded' | 'raw' | 'file' | 'graphql'; + +export interface RequestBodyOptions { + mode: RequestBodyMode; + file?: string; + formdata?: { key: string; value: string }[]; + graphql?: object; + raw?: string; + urlencoded?: { key: string; value: string }[]; +} + +export interface ResponseContentInfo { + mimeType: string; + mimeFormat: string; + charset: string; + fileExtension: string; + fileName: string; + contentType: string; +} + +class FormParam { + key: string; + value: string; + + constructor(options: { key: string; value: string }) { + this.key = options.key; + this.value = options.value; + } + + // TODO + // (static) _postman_propertyAllowsMultipleValues :Boolean + // (static) _postman_propertyIndexKey :String + + // not implemented either + // static parse(_: FormParam) { + // throw Error('unimplemented yet'); + // } + + toJSON() { + return { key: this.key, value: this.value }; + } + + toString() { + return `${this.key}=${this.value}`; // validate key, value contains '=' + } + + valueOf() { + return this.value; + } +} + +export class RequestBody extends PropertyBase { + mode: RequestBodyMode; // type of request data + file?: string; // It can be a file path (when used with Node.js) or a unique ID (when used with the browser). + formdata?: PropertyList; + graphql?: object; // raw graphql data + options?: object; // request body options + raw?: string; // raw body + urlencoded?: PropertyList; // URL encoded body params + + constructor(opts: RequestBodyOptions) { + super({ description: '' }); + + this.file = opts.file; + this.formdata = opts.formdata ? + new PropertyList( + opts.formdata. + map(formParamObj => new FormParam({ key: formParamObj.key, value: formParamObj.value })) + ) : + undefined; + this.graphql = opts.graphql; + this.mode = opts.mode; + // this.options = opts.options; + this.raw = opts.raw; + + if (typeof opts.urlencoded === 'string') { + const queryParamObj = QueryParam.parse(opts.urlencoded); + this.urlencoded = opts.urlencoded ? + new PropertyList( + Object.entries(queryParamObj) + .map(entry => ({ key: entry[0], value: JSON.stringify(entry[1]) })) + .map(kv => new QueryParam(kv)), + ) : + undefined; + } else { + // TODO: validate key, value in each entry + this.urlencoded = opts.urlencoded ? + new PropertyList( + opts.urlencoded + .map(entry => ({ key: entry.key, value: entry.value })) + .map(kv => new QueryParam(kv)), + ) : + undefined; + } + } + + isEmpty() { + switch (this.mode) { + case 'formdata': + return this.formdata == null; + case 'urlencoded': + return this.urlencoded == null; + case 'raw': + return this.raw == null; + case 'file': + return this.file == null; + case 'graphql': + return this.graphql == null; + default: + throw Error(`mode (${this.mode}) is unexpected`); + } + } + + toString() { + try { + switch (this.mode) { + case 'formdata': + return this.formdata ? this.formdata?.toString() : ''; + case 'urlencoded': + return this.urlencoded ? this.urlencoded.toString() : ''; + case 'raw': + return this.raw ? this.raw.toString() : ''; + case 'file': + return this.file || ''; // TODO: check file id or file content + case 'graphql': + return this.graphql ? JSON.stringify(this.graphql) : ''; + default: + throw Error(`mode (${this.mode}) is unexpected`); + } + } catch (e) { + return ''; + } + } + + update(opts: RequestBodyOptions) { + this.file = opts.file; + this.formdata = opts.formdata ? + new PropertyList( + opts.formdata. + map(formParamObj => new FormParam({ key: formParamObj.key, value: formParamObj.value })) + ) : + undefined; + this.graphql = opts.graphql; + this.mode = opts.mode; + // this.options = opts.options; + this.raw = opts.raw; + + if (typeof opts.urlencoded === 'string') { + const queryParamObj = QueryParam.parse(opts.urlencoded); + this.urlencoded = opts.urlencoded ? + new PropertyList( + Object.entries(queryParamObj) + .map(entry => ({ key: entry[0], value: JSON.stringify(entry[1]) })) + .map(kv => new QueryParam(kv)), + ) : + undefined; + } else { + // TODO: validate key, value in each entry + this.urlencoded = opts.urlencoded ? + new PropertyList( + opts.urlencoded + .map(entry => ({ key: entry.key, value: JSON.stringify(entry.value) })) + .map(kv => new QueryParam(kv)), + ) : + undefined; + } + } +} + +export interface RequestOptions { + url: string | Url; + method: string; + header: HeaderOptions[]; + body: RequestBodyOptions; + auth: AuthOptions; + proxy: ProxyConfigOptions; + certificate: CertificateOptions; +} + +export interface RequestSize { + body: number; + header: number; + total: number; + source: string; +} + +export class Request extends Property { + kind: string = 'Request'; + + url: Url; + method: string; + headers: HeaderList
; + body?: RequestBody; + auth: RequestAuth; + proxy: ProxyConfig; + certificate?: Certificate; + + constructor(options: RequestOptions) { + super(); + + this.url = typeof options.url === 'string' ? new Url(options.url) : options.url; + this.method = options.method; + this.headers = new HeaderList( + undefined, + options.header.map(header => new Header(header)), + ); + this.body = new RequestBody(options.body); + this.auth = new RequestAuth(options.auth); + this.proxy = new ProxyConfig(options.proxy); + this.certificate = new Certificate(options.certificate); + } + + static isRequest(obj: object) { + return 'kind' in obj && obj.kind === 'Request'; + } + + addHeader(header: Header | object) { + if (Header.isHeader(header)) { + const headerInstance = header as Header; + this.headers.add(headerInstance); + } else if ('key' in header && 'value' in header) { + const headerInstance = new Header(header); + this.headers.add(headerInstance); + } else { + throw Error('header must be Header | object'); + } + } + + addQueryParams(params: QueryParam[] | string) { + this.url.addQueryParams(params); + } + + authorizeUsing(authType: string | AuthOptions, options?: VariableList) { + const selectedAuth = typeof authType === 'string' ? authType : authType.type; + this.auth.use(selectedAuth, options || {}); + } + + clone() { + return new Request({ + url: this.url, + method: this.method, + header: this.headers.map(header => header.toJSON(), {}), + body: { + mode: this.body?.mode, + file: this.body?.file, + formdata: this.body?.formdata?.map(formParam => formParam.toJSON(), {}), + graphql: this.body?.graphql, + raw: this.body?.raw, + urlencoded: this.body?.urlencoded?.map(queryParam => queryParam.toJSON(), {}), + }, + auth: this.auth.toJSON(), + proxy: { + match: this.proxy.match, + host: this.proxy.host, + port: this.proxy.port, + tunnel: this.proxy.tunnel, + disabled: this.proxy.disabled, + authenticate: this.proxy.authenticate, + username: this.proxy.username, + password: this.proxy.password, + }, + certificate: { + name: this.certificate?.name, + matches: this.certificate?.matches?.map(match => match.toString(), {}), + key: this.certificate?.key, + cert: this.certificate?.cert, + passphrase: this.certificate?.passphrase, + pfx: this.certificate?.pfx, + }, + }); + } + + forEachHeader(callback: (header: Header, context?: object) => void) { + this.headers.each(callback, {}); + } + + getHeaders(options?: { + ignoreCase: boolean; + enabled: boolean; + multiValue: boolean; + sanitizeKeys: boolean; + }) { + // merge headers with same key into an array + const headerMap = new Map(); + this.headers.each(header => { + const enabled = options?.enabled ? !header.disabled : true; + const sanitized = options?.sanitizeKeys ? !!header.value : true; + const hasName = !!header.key; + + if (!enabled || !sanitized || !hasName) { + return; + } + + header.key = options?.ignoreCase ? header.key?.toLocaleLowerCase() : header.key; + + if (headerMap.has(header.key)) { + const existingHeader = headerMap.get(header.key) || []; + headerMap.set(header.key, [...existingHeader, header.value]); + } else { + headerMap.set(header.key, [header.value]); + } + }, {}); + + const obj: Record = {}; + Array.from(headerMap.entries()) + .forEach(headerEntry => { + obj[headerEntry[0]] = headerEntry[1]; + }); + + return obj; + } + + removeHeader(toRemove: string | Header, options: { ignoreCase: boolean }) { + const filteredHeaders = this.headers.filter( + header => { + if (!header.key) { + return false; + } + + if (typeof toRemove === 'string') { + return options.ignoreCase ? + header.key.toLocaleLowerCase() !== toRemove.toLocaleLowerCase() : + header.key !== toRemove; + } else if ('name' in toRemove) { + if (!toRemove.key) { + return false; + } + + return options.ignoreCase ? + header.key.toLocaleLowerCase() !== toRemove.key.toLocaleLowerCase() : + header.key !== toRemove.key; + } else { + throw Error('type of the "toRemove" must be: string | Header'); + } + }, + {}, + ); + + this.headers = new HeaderList(undefined, filteredHeaders); + } + + removeQueryParams(params: string | string[] | QueryParam[]) { + this.url.removeQueryParams(params); + } + + // TODO: + // size() → { Object } + + toJSON() { + return { + url: this.url, + method: this.method, + header: this.headers.map(header => header.toJSON(), {}), + body: { + mode: this.body?.mode, + file: this.body?.file, + formdata: this.body?.formdata?.map(formParam => formParam.toJSON(), {}), + graphql: this.body?.graphql, + raw: this.body?.raw, + urlencoded: this.body?.urlencoded?.map(queryParam => queryParam.toJSON(), {}), + }, + auth: this.auth.toJSON(), + proxy: { + match: this.proxy.match, + host: this.proxy.host, + port: this.proxy.port, + tunnel: this.proxy.tunnel, + disabled: this.proxy.disabled, + authenticate: this.proxy.authenticate, + username: this.proxy.username, + password: this.proxy.password, + }, + certificate: { + name: this.certificate?.name, + matches: this.certificate?.matches?.map(match => match.toString(), {}), + key: this.certificate?.key, + cert: this.certificate?.cert, + passphrase: this.certificate?.passphrase, + pfx: this.certificate?.pfx, + }, + }; + } + + update(options: RequestOptions) { + this.url = typeof options.url === 'string' ? new Url(options.url) : options.url; + this.method = options.method; + this.headers = new HeaderList( + undefined, + options.header.map(header => new Header(header)), + ); + this.body = new RequestBody(options.body); + this.auth = new RequestAuth(options.auth); + this.proxy = new ProxyConfig(options.proxy); + this.certificate = new Certificate(options.certificate); + } + + upsertHeader(header: HeaderOptions) { + // remove keys with same name + this.headers = new HeaderList( + undefined, + this.headers + .filter( + existingHeader => existingHeader.key !== header.key, + {}, + ) + ); + + // append new + this.headers.append(new Header(header)); + } +} + +export interface ResponseOptions { + code: number; + reason?: string; + header?: HeaderOptions[]; + cookie?: CookieOptions[]; + body?: string; + // originally stream's type is ‘Buffer | ArrayBuffer’, but it should work in both browser and node + stream?: ArrayBuffer; + responseTime: number; + status?: string; +} + +export class Response extends Property { + kind: string = 'Response'; + + body: string; + code: number; + cookies: CookieList; + headers: HeaderList
; + // originalRequest: Request; + responseTime: number; + status: string; + stream?: ArrayBuffer; + + constructor(options: ResponseOptions) { + super(); + + this.body = options.body || ''; + this.code = options.code; + this.cookies = new CookieList( + undefined, + options.cookie?.map(cookie => new Cookie(cookie)) || [], + ); + this.headers = new HeaderList( + undefined, + options.header?.map(headerOpt => new Header(headerOpt)) || [], + ); + // TODO: how to init request? + // this.originalRequest = options.originalRequest; + this.responseTime = options.responseTime; + this.status = RESPONSE_CODE_REASONS[options.code]; + this.stream = options.stream; + } + + // TODO: accurate type of response should be given + static createFromNode( + response: { + body: string; + headers: HeaderOptions[]; + statusCode: number; + statusMessage: string; + elapsedTime: number; + }, + cookies: CookieOptions[], + ) { + // TODO: revisit this converting + const buf = new ArrayBuffer(response.body ? response.body.length * 2 : 0); // 2 bytes for each char + const bufView = new Uint16Array(buf); + const strLen = response.body.length; + for (let i = 0; i < strLen; i++) { + bufView[i] = response.body.charCodeAt(i); + } + + return new Response({ + cookie: cookies, + body: response.body.toString(), + stream: bufView.buffer, + header: response.headers, + code: response.statusCode, + status: response.statusMessage, + responseTime: response.elapsedTime, + }); + } + + static isResponse(obj: object) { + return 'kind' in obj && obj.kind === 'Response'; + } + + contentInfo(): ResponseContentInfo { + const contentType = this.headers.get('Content-Type'); + // TODO: + // const contentDisposition = this.headers.get('Content-Disposition'); + // const mimeInfo = getMimeInfo(contentType, response.stream || response.body); + // let fileName = getFileNameFromDispositionHeader(contentDisposition); + const mimeInfo = { + extension: '', + mimeType: '', + mimeFormat: '', + charset: '', + contentType: '', + }; + let fileName = ''; + const fileExtension = mimeInfo.extension; + + if (!fileName) { + fileName = 'response'; + fileName += `.${fileExtension}` || ''; + } + + // contentType = mimeInfo.contentType || contentType; + + return { + mimeType: mimeInfo.mimeType, + mimeFormat: mimeInfo.mimeFormat, + charset: mimeInfo.charset, + fileExtension: fileExtension, + fileName: fileName, + contentType: contentType?.valueOf() || 'text/plain', + }; + } + + dataURI() { + const contentInfo = this.contentInfo(); + const bodyInBase64 = this.stream || this.body; + if (!bodyInBase64) { + throw Error('dataURI() failed as response body is not defined'); + } + + return `data:${contentInfo.contentType};baseg4, `; + } + + // need a library for this + // json(reviver?, strict?) { + + // } + + // jsonp(reviver?, strict?) { + + // } + + reason() { + return this.status; + } + + // TODO: + // size(): number { + + // } + + text() { + return this.body; + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/require.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/require.ts new file mode 100644 index 0000000000..4528027009 --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/require.ts @@ -0,0 +1,19 @@ +import * as uuid from 'uuid'; + +const builtinModules = new Map([ + ['uuid', uuid], +]); + +const nodeModules = new Map([]); + +export function require(moduleName: string) { + if (builtinModules.has(moduleName)) { + return builtinModules.get(moduleName); + } + + if (nodeModules.has(moduleName)) { + // invoke main.js + } + + throw Error(`no module is found for "${moduleName}"`); +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/urls.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/urls.ts new file mode 100644 index 0000000000..42d5ef4cbb --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/urls.ts @@ -0,0 +1,563 @@ +import queryString from 'query-string'; + +import { Property, PropertyBase, PropertyList } from './base'; +import { Variable, VariableList } from './variables'; + +// export class QueryParam extends Property { +// key: string = ''; +// value: string = ''; + +// constructor(options: { +// id?: string; +// name?: string; +// key: string; +// value: string; +// }) { +// super(); + +// this.id = options.id ? options.id : ''; +// this.name = options.name ? options.name : ''; +// this.key = options.key; +// this.value = options.value; +// } + +// // TODO: improve following fields +// static _postman_propertyAllowsMultipleValues: boolean = true; +// static _postman_propertyIndexKey: string = 'formData'; + +// // parse a form data string into an array of objects, where each object contains a key and a value. +// static parse(query: string): { key: string; value: string }[] { +// try { +// const keyValues = JSON.parse(query); +// return keyValues.filter((keyValue: object) => { +// if (!('key' in keyValue) || !('value' in keyValue)) { +// console.error('ignored some formdata as "key" or "value" is not found in it'); +// return false; +// } +// return true; +// }); +// } catch (e) { +// console.error(`failed to parse QueryParams: ${e.message}`); +// return []; +// } +// } + +// valueOf() { +// return this.value; +// } +// } + +export class QueryParam extends Property { + key: string; + value: string; + + constructor(options: { key: string; value: string } | string) { + super(); + + if (typeof options === 'string') { + try { + const optionsObj = JSON.parse(options); + // validate key and value fields + this.key = optionsObj.key; + this.value = optionsObj.value; + } catch (e) { + throw Error(`invalid QueryParam options ${e}`); + } + } else if (typeof options === 'object' && ('key' in options) && ('value' in options)) { + this.key = options.key; + this.value = options.value; + } else { + throw Error('unknown options for new QueryParam'); + } + } + + // TODO: + // (static) _postman_propertyAllowsMultipleValues :Boolean + // (static) _postman_propertyIndexKey :String + + static parse(queryStr: string) { + // this may not always be executed in the browser + return queryString.parse(queryStr); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + static parseSingle(param: string, _idx?: number, _all?: string[]) { + // it seems that _idx and _all are not useful + return queryString.parse(param); + + } + + static unparse(params: object) { + return Object.entries(params) + .map(entry => `${entry[0]}=${entry[1] || ''}`) + .join('&'); + } + + static unparseSingle(obj: { key: string; value: string }) { + if ('key' in obj && 'value' in obj) { + // TODO: validate and unescape + return `${obj.key}=${obj.value}`; + } + return {}; + } + + toString() { + return `${this.key}=${this.value}`; // validate key, value contains '=' + } + + update(param: string | { key: string; value: string }) { + if (typeof param === 'string') { + const paramObj = QueryParam.parse(param); + this.key = typeof paramObj.key === 'string' ? paramObj.key : ''; + this.value = typeof paramObj.value === 'string' ? paramObj.value : ''; + } else if ('key' in param && 'value' in param) { + this.key = param.key; + this.value = param.value; + } else { + throw Error('the param for update must be: string | { key: string; value: string }'); + } + } +} + +export interface UrlObject { + auth: { + username: string; + password: string; + } | undefined; + hash: string; + host: string[]; + path: string[]; + port: string; + protocol: string; + query: { key: string; value: string }[]; + variables: { key: string; value: string }[]; +} + +export class Url extends PropertyBase { + kind: string = 'Url'; + + auth?: { username: string; password: string }; + hash?: string; + host: string[]; + path?: string[]; + port?: string; + protocol?: string; + query: PropertyList; + variables: VariableList; + + // TODO: user could pass anything + constructor(def: { + auth?: { username: string; password: string }; // TODO: should be related to RequestAuth + hash?: string; + host: string[]; + path?: string[]; + port?: string; + protocol: string; + query: PropertyList; + variables: VariableList; + } | string) { + super({ description: 'Url' }); + + if (typeof def === 'string') { + const urlObj = Url.parse(def); + + if (urlObj) { + this.auth = urlObj.auth; + this.hash = urlObj.hash; + this.host = urlObj.host; + this.path = urlObj.path; + this.port = urlObj.port; + this.protocol = urlObj.protocol; + + const queryList = urlObj.query ? urlObj.query.map(kvObj => new QueryParam(kvObj)) : []; + this.query = new PropertyList(queryList); + const varList = urlObj.variables ? + urlObj.variables + .map((kvObj: { key: string; value: string }) => new Variable(kvObj)) : + []; + this.variables = new VariableList(undefined, varList); + } else { + throw Error(`url is invalid: ${def}`); // TODO: + } + } else { + this.auth = def.auth ? { username: def.auth.username, password: def.auth.password } : undefined; + this.hash = def.hash ? def.hash : ''; + this.host = def.host; + this.path = def.path ? def.path : []; + this.port = def.port ? def.port : ''; + this.protocol = def.protocol ? def.protocol : ''; + this.query = def.query ? def.query : new PropertyList([]); + this.variables = def.variables ? def.variables : new VariableList(undefined, new Array()); + } + } + + static isUrl(obj: object) { + return 'kind' in obj && obj.kind === 'Url'; + } + + static parse(urlStr: string): UrlObject | undefined { + if (!URL.canParse(urlStr)) { + console.error(`invalid URL string ${urlStr}`); + return undefined; + } + + const url = new URL(urlStr); + const query = Array.from(url.searchParams.entries()) + .map((kv: [string, string]) => { + return { key: kv[0], value: kv[1] }; + }); + + return { + auth: url.username !== '' ? { // TODO: make it compatible with RequestAuth + username: url.username, + password: url.password, + } : undefined, + hash: url.hash, + host: url.hostname.split('/'), + path: url.pathname.split('/'), + port: url.port, + protocol: url.protocol, // e.g. https: + query, + variables: [], + }; + } + + addQueryParams(params: { key: string; value: string }[] | string) { + let queryParams: { key: string; value: string }[]; + + if (typeof params === 'string') { + queryParams = QueryParam.parse(params); + } else { + queryParams = params; + } + + queryParams.forEach((param: { key: string; value: string }) => { + this.query.append(new QueryParam({ key: param.key, value: param.value })); + }); + } + + getHost() { + return this.host.join('/'); + } + + getPath(unresolved?: boolean) { + const path = this.path ? this.path.join('/') : ''; + + if (unresolved) { + return '/' + path; + } + // TODO: handle variables + return path; + } + + getPathWithQuery() { + const path = this.path ? this.path.join('/') : ''; + const pathHasPrefix = path.startsWith('/') ? path : '/' + path; + + const query = this.query + .map(param => `${param.key}=${param.value}`, {}) + .join('&'); + + return `${pathHasPrefix}?${query}`; + } + + getQueryString() { + return this.query + .map(queryParam => (`${queryParam.key}=${queryParam.key}`), {}) + .join('&'); + } + + // TODO: + getRemote(forcePort?: boolean) { + const host = this.host.join('/'); + if (forcePort) { + const port = this.protocol && (this.protocol === 'https:') ? 443 : 80; // TODO: support ws, gql, grpc + return `${host}:${port}`; + } + return this.port ? `${host}:${this.port}` : `${host}`; + } + + removeQueryParams(params: QueryParam[] | string[] | string) { + if (typeof params === 'string') { + // it is a string + this.query = new PropertyList( + this.query.filter(queryParam => queryParam.key === params, {}) + ); + } else if (params.length > 0) { + let toBeRemoved: Set; + + if (typeof params[0] === 'string') { + // it is a string[] + toBeRemoved = new Set(params as string[]); + } else { + // it is a QueryParam[] + toBeRemoved = new Set( + (params as QueryParam[]) + .map(param => param.key) + ); + } + + this.query = new PropertyList( + this.query.filter(queryParam => !toBeRemoved.has(queryParam.key), {}) + ); + } else { + console.error('failed to remove query params: unknown params type, only supports QueryParam[], string[] or string'); + } + } + + toString(forceProtocol?: boolean) { + const queryStr = this.query + .map(param => `${param.key}=${param.value}`, {}) + .join('&'); + + const auth = this.auth ? `${this.auth.username}:${this.auth.password}@` : ''; + if (forceProtocol) { + return `${this.protocol}//${auth}${this.host}:${this.port}${this.path}?${queryStr}#${this.hash}`; // TODO: improve auth + } + return `${auth}${this.host}:${this.port}${this.path}?${queryStr}#${this.hash}`; + } + + update(url: string | object) { + // user could pass anything in script + + if (typeof url === 'string') { + const urlObj = Url.parse(url); + if (urlObj) { + this.auth = urlObj.auth ? { + username: urlObj.auth.username, + password: urlObj.auth.password, + } : { + username: '', + password: '', + }; + this.hash = urlObj.hash ? urlObj.hash : ''; + this.host = urlObj.host ? urlObj.host : []; + this.path = urlObj.path ? urlObj.path : []; + this.port = urlObj.port ? urlObj.port : ''; + this.protocol = urlObj.protocol ? urlObj.protocol : ''; + this.query = urlObj.query ? + new PropertyList(urlObj.query.map(kv => new QueryParam(kv))) : + new PropertyList([]); + // TODO: update variables + // this.variables = new VariableList(undefined, new Array()); + } else { + throw Error(`failed to parse url: ${url}`); + } + } else { + if ('auth' in url && typeof url.auth === 'object' && url.auth) { + if ('username' in url.auth + && typeof url.auth.username === 'string' + && 'password' in url.auth + && typeof url.auth.password === 'string' + ) { + this.auth = { username: url.auth.username, password: url.auth.password }; + } else { + console.error('the auth field must have "username" and "password" fields'); + } + } + + if ('hash' in url && typeof url.hash === 'string') { + this.hash = url.hash; + } else { + this.hash = ''; + } + + if ('host' in url && Array.isArray(url.host)) { + const isStringArray = url.host.length > 0 ? typeof url.host[0] === 'string' : true; + if (isStringArray) { + this.host = url.host; + } else { + console.error('type of "host" is invalid'); + } + } else { + this.host = []; + } + + if ('path' in url && Array.isArray(url.path)) { + const isStringArray = url.path.length > 0 ? typeof url.path[0] === 'string' : true; + if (isStringArray) { + this.path = url.path; + } else { + console.error('type of "path" is invalid'); + } + } else { + this.path = []; + } + + this.port = 'port' in url && url.port && typeof url.port === 'string' ? url.port : ''; + this.protocol = 'protocol' in url && url.protocol && typeof url.protocol === 'string' ? url.protocol : ''; + + if ('query' in url && Array.isArray(url.query)) { + const queryParams = url.query + .filter(obj => 'key' in obj && 'value' in obj) + .map(kv => new QueryParam(kv)); + + this.query = new PropertyList(queryParams); + } + + // TODO: update variables + // this.variables = new VariableList(undefined, new Array()); + } + } +} + +// UrlMatchPattern implements chrome extension match patterns: https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns +export class UrlMatchPattern extends Property { + // scheme + scheme: 'http:' | 'https:' | '*' | 'file:'; + // host + // About wildcard: + // If you use a wildcard in the host pattern + // it must be the first or only character, and it must be followed by a period (.) or forward slash (/). + host: string; + // path: + // Must contain at least a forward slash + // The slash by itself matches any path. + path: string; + + private port: string; + + // Special cases: https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns#special + // "" + // "file:///" + // "http://localhost/*" + // It doesn't support match patterns for top Level domains (TLD). + + constructor(pattern: string) { + super(); + + const patternObj = UrlMatchPattern.parseAndValidate(pattern); + this.scheme = patternObj.scheme; + this.host = patternObj.host.join('/'); + this.path = patternObj.path.join('/'); + this.port = patternObj.port; + } + + static parseAndValidate(pattern: string): { + scheme: 'http:' | 'https:' | '*' | 'file:'; + host: string[]; + path: string[]; + port: string; + } { + // TODO: validate the pattern + const urlObj = Url.parse(pattern); + + if (!urlObj || urlObj.host.length === 0) { + throw Error(`match pattern (${pattern}) is invalid and failed to parse`); + } + + if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:' && urlObj.protocol !== '*' && urlObj.protocol !== 'file:') { + throw Error(`scheme (${urlObj.protocol}) is invalid and failed to parse`); + } + + return { scheme: urlObj.protocol, host: urlObj.host, path: urlObj.path, port: `${urlObj.port}` }; + } + + static readonly MATCH_ALL_URLS: string = ''; + static pattern: string | undefined = undefined; // TODO: its usage is unknown + static readonly PROTOCOL_DELIMITER: string = '+'; + + // TODO: the url can not start with - + private readonly starRegPattern = '[a-zA-Z0-9\-]*'; + + getProtocols(): string[] { + switch (this.scheme) { + case 'http:': + return ['http']; + case 'https:': + return ['https']; + case '*': + return ['http', 'https']; + case 'file:': + return ['file']; + default: + throw `invalid scheme ${this.scheme}`; + } + } + + test(urlStr: string) { + const urlObj = Url.parse(urlStr); + if (!urlObj) { + return false; + } + + return this.testProtocol(urlObj.protocol) + && this.testHost(urlObj.host.join('/')) + && this.testPort(urlObj.port, urlObj.protocol) + && this.testPath(urlObj.path.join('/')); + } + + testHost(host: string) { + const hostRegPattern = new RegExp(this.host.replace('*', this.starRegPattern), 'ig'); + return hostRegPattern.test(host); + } + + testPath(path: string) { + const pathRegPattern = new RegExp(this.path.replace('*', this.starRegPattern), 'ig'); + return pathRegPattern.test(path); + } + + // TODO: it is confused to verify both port and protocol + // testPort verifies both port and protocol, but not the relationship between them + testPort(port: string, protocol: string) { + if (!this.testProtocol(protocol)) { + return false; + } + + const portRegPattern = new RegExp(this.port.replace('*', this.starRegPattern), 'ig'); + if (!portRegPattern.test(port)) { + return false; + } + + return true; + } + + testProtocol(protocol: string) { + switch (protocol) { + case 'http:': + return this.scheme === 'http:' || this.scheme === '*'; + case 'https:': + return this.scheme === 'https:' || this.scheme === '*'; + case '*': + return this.scheme === 'http:' || this.scheme === 'https:' || this.scheme === '*'; + case 'file:': + return this.scheme === 'file:'; + default: + throw `invalid scheme ${protocol}`; + } + } + + toString() { + return `${this.scheme}//${this.host}${this.path}`; + } + + update(pattern: string) { + const patternObj = UrlMatchPattern.parseAndValidate(pattern); + this.scheme = patternObj.scheme; + this.host = patternObj.host.join('/'); + this.path = patternObj.path.join('/'); + this.port = patternObj.port; + } +} + +export class UrlMatchPatternList extends PropertyList { + kind: string = 'UrlMatchPatternList'; + + constructor(parent: PropertyList | undefined, populate: T[]) { + super(populate); + this._parent = parent; + } + + static isUrlMatchPatternList(obj: any) { + return 'kind' in obj && obj.kind === 'UrlMatchPatternList'; + } + + // TODO: unsupported yet + // toObject(excludeDisabledopt, nullable, caseSensitiveopt, nullable, multiValueopt, nullable, sanitizeKeysopt) → {Object} + + test(urlStr: string) { + return this + .filter(matchPattern => matchPattern.test(urlStr), {}) + .length > 0; + } +} diff --git a/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/variables.ts b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/variables.ts new file mode 100644 index 0000000000..de8f0b1548 --- /dev/null +++ b/packages/insomnia/src/renderers/hidden-browser-window/sdk-objects/variables.ts @@ -0,0 +1,59 @@ +import { Property, PropertyList } from './base'; + +export class Variable extends Property { + key: string; + value: any; + type: string; + kind: string = 'Variable'; + + constructor(def?: { + id?: string; + key: string; + name?: string; + value: string; + type?: string; + disabled?: boolean; + }) { + super(); + + this.id = def ? def.id : ''; + this.key = def ? def.key : ''; + this.name = def ? def.name : ''; + this.value = def ? def.value : ''; + this.type = def && def.type ? def.type : 'Variable'; + this.disabled = def ? def.disabled : false; + } + + // unknown usage and unsupported + // static readonly types() => { + // } + + // cast typecasts a value to the Variable.types of this Variable. + cast(value: any) { + if ('kind' in value && value.kind === 'Variable') { + return value.value; + } + return undefined; + } + + get() { + return this.value; + } + + set(value: any) { + this.value = value; + } +} + +export class VariableList extends PropertyList { + kind: string = 'VariableList'; + + constructor(parent: PropertyList | undefined, populate: T[]) { + super(populate); + this._parent = parent; + } + + static isVariableList(obj: any) { + return 'kind' in obj && obj.kind === 'VariableList'; + } +}