UNPKG

insomnia-importers

Version:

Various data importers for Insomnia

776 lines (676 loc) 22.9 kB
import SwaggerParser from '@apidevtools/swagger-parser'; import { camelCase } from 'change-case'; import crypto from 'crypto'; import { OpenAPIV2, OpenAPIV3 } from 'openapi-types'; import { isPlainObject } from 'ramda-adjunct'; import { parse as urlParse } from 'url'; import YAML from 'yaml'; import { Authentication, Converter, ImportRequest } from '../entities'; import { unthrowableParseJson } from '../utils'; export const id = 'openapi3'; export const name = 'OpenAPI 3.0'; export const description = 'Importer for OpenAPI 3.0 specification (json/yaml)'; /* eslint-disable camelcase -- some camecase is required by the parsing of the spec itself */ const SUPPORTED_OPENAPI_VERSION = /^3\.\d+\.\d+$/; // 3.x.x const MIMETYPE_JSON = 'application/json'; const MIMETYPE_LITERALLY_ANYTHING = '*/*'; const SUPPORTED_MIME_TYPES = [MIMETYPE_JSON, MIMETYPE_LITERALLY_ANYTHING]; const WORKSPACE_ID = '__WORKSPACE_ID__'; const SECURITY_TYPE = { HTTP: 'http', API_KEY: 'apiKey', OAUTH: 'oauth2', OPEN_ID: 'openIdConnect', }; const HTTP_AUTH_SCHEME = { BASIC: 'basic', BEARER: 'bearer', }; const OAUTH_FLOWS = { AUTHORIZATION_CODE: 'authorizationCode', CLIENT_CREDENTIALS: 'clientCredentials', IMPLICIT: 'implicit', PASSWORD: 'password', } as const; const SUPPORTED_SECURITY_TYPES = [ SECURITY_TYPE.HTTP, SECURITY_TYPE.API_KEY, SECURITY_TYPE.OAUTH, ]; const SUPPORTED_HTTP_AUTH_SCHEMES = [ HTTP_AUTH_SCHEME.BASIC, HTTP_AUTH_SCHEME.BEARER, ]; const VARIABLE_SEARCH_VALUE = /{([^}]+)}/g; let requestCounts: Record<string, number> = {}; /** * Gets a server to use as the default * Either the first server defined in the specification, or an example if none are specified * * @returns the resolved server URL */ const getDefaultServerUrl = (api: OpenAPIV3.Document) => { const exampleServer = 'http://example.com/'; const servers = api.servers || []; const firstServer = servers[0]; const foundServer = firstServer && firstServer.url; if (!foundServer) { return urlParse(exampleServer); } const url = resolveVariables(firstServer); return urlParse(url); }; /** * Resolve default variables for a server url * * @returns the resolved url */ const resolveVariables = (server: OpenAPIV3.ServerObject) => { let resolvedUrl = server.url; const variables = server.variables || {}; let shouldContinue = true; do { // Regexp contain the global flag (g), meaning we must execute our regex on the original string. // https://stackoverflow.com/a/27753327 const [replace, name] = VARIABLE_SEARCH_VALUE.exec(server.url) || []; const variable = variables && variables[name]; const value = variable && variable.default; if (name && !value) { // We found a variable in the url (name) but we have no default to replace it with (value) throw new Error(`Server variable "${name}" missing default value`); } shouldContinue = !!name; resolvedUrl = replace ? resolvedUrl.replace(replace, value) : resolvedUrl; } while (shouldContinue); return resolvedUrl; }; /** * Parse string data into openapi 3 object (https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#oasObject) */ const parseDocument = (rawData: string): OpenAPIV3.Document | null => { try { return (unthrowableParseJson(rawData) || YAML.parse(rawData)) as OpenAPIV3.Document; } catch (err) { return null; } }; export type SpecExtension = `x-${string}`; /** * Checks if the given property name is an open-api extension * @param property The property name */ const isSpecExtension = (property: string): property is SpecExtension => { return property.indexOf('x-') === 0; }; /** * Create request definitions based on openapi document. */ const parseEndpoints = (document?: OpenAPIV3.Document | null) => { if (!document) { return []; } const rootSecurity = document.security; const securitySchemes = document.components?.securitySchemes as OpenAPIV3.SecuritySchemeObject | undefined; const defaultParent = WORKSPACE_ID; const endpointsSchemas: ({ path: string; method: string; tags?: string[]; } & OpenAPIV3.SchemaObject)[] = Object.keys(document.paths) .map(path => { const schemasPerMethod = document.paths[path]; if (!schemasPerMethod) { return []; } const methods = Object.entries(schemasPerMethod) // Only keep entries that are plain objects and not spec extensions .filter(([key, value]) => isPlainObject(value) && !isSpecExtension(key)); return methods.map(([method]) => ({ ...((schemasPerMethod as Record<string, OpenAPIV3.SchemaObject>)[method]), path, method, })); }) .flat(); const folders = document.tags?.map(importFolderItem(defaultParent)) || []; const folderLookup = folders.reduce((accumulator, folder) => ({ ...accumulator, ...(folder.name ? { [folder.name]: folder._id } : {}), }), {} as Record<OpenAPIV3.TagObject['name'], string | undefined>); const requests: ImportRequest[] = []; endpointsSchemas.forEach(endpointSchema => { let { tags } = endpointSchema; if (!tags || tags.length === 0) { tags = ['']; } tags.forEach(tag => { const parentId = folderLookup[tag] || defaultParent; const resolvedSecurity = (endpointSchema as unknown as OpenAPIV3.Document).security || rootSecurity; requests.push( importRequest( endpointSchema, parentId, resolvedSecurity, securitySchemes, ), ); }); }); return [ ...folders, ...requests, ]; }; /** * Return Insomnia folder / request group */ const importFolderItem = (parentId: string) => ( item: OpenAPIV3.SchemaObject, ): ImportRequest => { const hash = crypto .createHash('sha1') // @ts-expect-error -- this is not present on the official types, yet was here in the source code .update(item.name) .digest('hex') .slice(0, 8); return { parentId, _id: `fld___WORKSPACE_ID__${hash}`, _type: 'request_group', // @ts-expect-error -- this is not present on the official types, yet was here in the source code name: item.name || 'Folder {requestGroupCount}', description: item.description || '', }; }; /** * Return path with parameters replaced by insomnia variables * * I.e. "/foo/:bar" => "/foo/{{ bar }}" */ const pathWithParamsAsVariables = (path?: string) => path?.replace(VARIABLE_SEARCH_VALUE, '{{ $1 }}') ?? ''; /** * Return Insomnia request */ const importRequest = ( endpointSchema: OpenAPIV3.SchemaObject & { summary?: string; path?: string; method?: string }, parentId: string, security?: OpenAPIV3.SecurityRequirementObject[], securitySchemes?: OpenAPIV3.SecuritySchemeObject, ): ImportRequest => { const name = endpointSchema.summary || endpointSchema.path; const id = generateUniqueRequestId(endpointSchema as OpenAPIV3.OperationObject); const paramHeaders = prepareHeaders(endpointSchema); const { authentication, headers: securityHeaders, parameters: securityParams, } = parseSecurity(security, securitySchemes); return { _type: 'request', _id: id, parentId: parentId, name, method: endpointSchema.method?.toUpperCase(), url: `{{ base_url }}${pathWithParamsAsVariables(endpointSchema.path)}`, body: prepareBody(endpointSchema), headers: [...paramHeaders, ...securityHeaders], authentication: authentication as Authentication, parameters: [...prepareQueryParams(endpointSchema), ...securityParams], }; }; /** * Imports insomnia definitions of query parameters. */ const prepareQueryParams = (endpointSchema: OpenAPIV3.PathItemObject) => { return convertParameters( endpointSchema.parameters?.filter(parameter => ( (parameter as OpenAPIV3.ParameterObject).in === 'query' )) as OpenAPIV3.ParameterObject[]); }; /** * Imports insomnia definitions of header parameters. */ const prepareHeaders = (endpointSchema: OpenAPIV3.PathItemObject) => { return convertParameters( endpointSchema.parameters?.filter(parameter => ( (parameter as OpenAPIV3.ParameterObject).in === 'header' )) as OpenAPIV3.ParameterObject[]); }; /** * Parse OpenAPI 3 securitySchemes into insomnia definitions of authentication, headers and parameters * @returns headers or basic|bearer http authentication details */ const parseSecurity = ( security?: OpenAPIV3.SecurityRequirementObject[], securitySchemes?: OpenAPIV3.SecuritySchemeObject, ) => { if (!security || !securitySchemes) { return { authentication: {}, headers: [], parameters: [], }; } const supportedSchemes = security .flatMap(securityPolicy => { return Object.keys(securityPolicy).map((securityRequirement: string | number) => { return { // @ts-expect-error the base types do not include an index but from what I can tell, they should schemeDetails: securitySchemes[securityRequirement], securityScopes: securityPolicy[securityRequirement], }; }); }) .filter(({ schemeDetails }) => ( schemeDetails && SUPPORTED_SECURITY_TYPES.includes(schemeDetails.type) )); const apiKeySchemes = supportedSchemes.filter(scheme => ( scheme.schemeDetails.type === SECURITY_TYPE.API_KEY )); const apiKeyHeaders = apiKeySchemes .filter(scheme => scheme.schemeDetails.in === 'header') .map(scheme => { const variableName = camelCase(scheme.schemeDetails.name); return { name: scheme.schemeDetails.name, disabled: false, value: `{{ ${variableName} }}`, }; }); const apiKeyCookies = apiKeySchemes .filter(scheme => scheme.schemeDetails.in === 'cookie') .map(scheme => { const variableName = camelCase(scheme.schemeDetails.name); return `${scheme.schemeDetails.name}={{ ${variableName} }}`; }); const apiKeyCookieHeader = { name: 'Cookie', disabled: false, value: apiKeyCookies.join('; '), }; const apiKeyParams = apiKeySchemes .filter(scheme => scheme.schemeDetails.in === 'query') .map(scheme => { const variableName = camelCase(scheme.schemeDetails.name); return { name: scheme.schemeDetails.name, disabled: false, value: `{{ ${variableName} }}`, }; }); if (apiKeyCookies.length > 0) { apiKeyHeaders.push(apiKeyCookieHeader); } const authentication = (() => { const authScheme = supportedSchemes.find( scheme => [SECURITY_TYPE.HTTP, SECURITY_TYPE.OAUTH].includes(scheme.schemeDetails.type) && (scheme.schemeDetails.type === SECURITY_TYPE.OAUTH || SUPPORTED_HTTP_AUTH_SCHEMES.includes(scheme.schemeDetails.scheme)), ); if (!authScheme) { return {}; } switch (authScheme.schemeDetails.type) { case SECURITY_TYPE.HTTP: return parseHttpAuth( (authScheme.schemeDetails as OpenAPIV3.HttpSecurityScheme).scheme, ); case SECURITY_TYPE.OAUTH: return parseOAuth2(authScheme.schemeDetails as OpenAPIV3.OAuth2SecurityScheme, authScheme.securityScopes); default: return {}; } })(); return { authentication, headers: apiKeyHeaders, parameters: apiKeyParams, }; }; /** * Get Insomnia environment variables for OpenAPI securitySchemes * * @returns Insomnia environment variables containing security information */ const getSecurityEnvVariables = (securitySchemeObject?: OpenAPIV3.SecuritySchemeObject) => { if (!securitySchemeObject) { return {}; } const securitySchemes = Object.values(securitySchemeObject); const apiKeyVariableNames = securitySchemes .filter(scheme => scheme.type === SECURITY_TYPE.API_KEY) .map(scheme => camelCase(scheme.name)); const variables: Record<string, string> = {}; Array.from(new Set(apiKeyVariableNames)).forEach(name => { variables[name] = name; }); const hasHttpBasicScheme = securitySchemes.some(scheme => ( scheme.type === SECURITY_TYPE.HTTP && scheme.scheme === 'basic' )); if (hasHttpBasicScheme) { variables.httpUsername = 'username'; variables.httpPassword = 'password'; } const hasHttpBearerScheme = securitySchemes.some(scheme => ( scheme.type === SECURITY_TYPE.HTTP && scheme.scheme === 'bearer' )); if (hasHttpBearerScheme) { variables.bearerToken = 'bearerToken'; } const oauth2Variables = securitySchemes.reduce((accumulator, scheme) => { if (scheme.type === SECURITY_TYPE.OAUTH) { accumulator.oauth2ClientId = 'clientId'; const flows = scheme.flows || {}; if ( flows.authorizationCode || flows.implicit ) { accumulator.oauth2RedirectUrl = 'http://localhost/'; } if ( flows.authorizationCode || flows.clientCredentials || flows.password ) { accumulator.oauth2ClientSecret = 'clientSecret'; } if (flows.password) { accumulator.oauth2Username = 'username'; accumulator.oauth2Password = 'password'; } } return accumulator; }, {}); return { ...variables, ...oauth2Variables, }; }; /** * Imports insomnia request body definitions, including data mock (if available) * * If multiple types are available, the one for which an example can be generated will be selected first (i.e. application/json) */ const prepareBody = (endpointSchema: OpenAPIV3.OperationObject): ImportRequest['body'] => { const { content } = (endpointSchema.requestBody || { content: {} }) as OpenAPIV3.RequestBodyObject; const mimeTypes = Object.keys(content); const supportedMimeType = SUPPORTED_MIME_TYPES.find(mimeType => mimeTypes.includes(mimeType), ); if (supportedMimeType === MIMETYPE_JSON) { const bodyParameter = content[supportedMimeType]; if (bodyParameter == null) { return { mimeType: MIMETYPE_JSON, }; } const example = generateParameterExample(bodyParameter.schema as OpenAPIV3.SchemaObject); const text = JSON.stringify(example, null, 2); return { mimeType: MIMETYPE_JSON, text, }; } if ( mimeTypes && mimeTypes.length && mimeTypes[0] !== MIMETYPE_LITERALLY_ANYTHING ) { return { mimeType: mimeTypes[0] || undefined, }; } else { return {}; } }; /** * Converts openapi schema of parameters into insomnia one. */ const convertParameters = (parameters: OpenAPIV3.ParameterObject[] = []) => { return parameters.map(parameter => { const { required, name, schema } = parameter; return { name, disabled: required !== true, value: `${generateParameterExample(schema as OpenAPIV3.SchemaObject)}`, }; }); }; /** * Generate example value of parameter based on it's schema. * Returns example / default value of the parameter, if any of those are defined. If not, returns value based on parameter type. */ // @ts-expect-error -- ran out of time during TypeScript conversion to handle this particular recursion const generateParameterExample = (schema: OpenAPIV3.SchemaObject | string) => { const typeExamples = { string: () => 'string', string_email: () => 'user@example.com', 'string_date-time': () => new Date().toISOString(), string_byte: () => 'ZXhhbXBsZQ==', number: () => 0, number_float: () => 0.0, number_double: () => 0.0, integer: () => 0, boolean: () => true, object: (schema: OpenAPIV3.SchemaObject) => { const example: OpenAPIV3.SchemaObject['properties'] = {}; const { properties } = schema; if (properties) { for (const propertyName of Object.keys(properties)) { example[propertyName] = generateParameterExample( properties[propertyName] as OpenAPIV3.SchemaObject, ); } } return example; }, // @ts-expect-error -- ran out of time during TypeScript conversion to handle this particular recursion array: (schema: OpenAPIV2.ItemsObject) => { // @ts-expect-error -- ran out of time during TypeScript conversion to handle this particular recursion const value = generateParameterExample(schema.items); if (schema.collectionFormat === 'csv') { return value; } else { return [value]; } }, }; if (typeof schema === 'string') { // @ts-expect-error -- ran out of time during TypeScript conversion to handle this particular recursion return typeExamples[schema]; } if (schema instanceof Object) { const { type, format, example, readOnly, default: defaultValue } = schema; if (readOnly) { return undefined; } if (example) { return example; } if (defaultValue) { return defaultValue; } // @ts-expect-error -- ran out of time during TypeScript conversion to handle this particular recursion const factory = typeExamples[`${type}_${format}`] || typeExamples[type]; if (!factory) { return null; } return factory(schema); } }; /** * Generates a unique and deterministic request ID based on the endpoint schema */ const generateUniqueRequestId = ( endpointSchema: OpenAPIV3.OperationObject<{ method?: string; path?: string }>, ) => { // `operationId` is already unique to the workspace, so we can just use that, combined with the workspace id to get something globally unique const uniqueKey = endpointSchema.operationId || `[${endpointSchema.method}]${endpointSchema.path}`; const hash = crypto .createHash('sha1') .update(uniqueKey) .digest('hex') .slice(0, 8); // Suffix the ID with a counter in case we try creating two with the same hash if (requestCounts.hasOwnProperty(hash)) { requestCounts[hash] += 1; } else { requestCounts[hash] = 0; } return `req_${WORKSPACE_ID}${hash}${requestCounts[hash] || ''}`; }; const parseHttpAuth = (scheme: string) => { switch (scheme) { case HTTP_AUTH_SCHEME.BASIC: return { type: 'basic', username: '{{ httpUsername }}', password: '{{ httpPassword }}', }; case HTTP_AUTH_SCHEME.BEARER: return { type: 'bearer', token: '{{bearerToken}}', prefix: '', }; default: return {}; } }; const parseOAuth2Scopes = ( flow: OpenAPIV3.OAuth2SecurityScheme['flows'][keyof OpenAPIV3.OAuth2SecurityScheme['flows']], selectedScopes: string[] ) => { if (!flow?.scopes) { return ''; } const scopes = Object.keys(flow.scopes || {}); return scopes.filter(scope => selectedScopes.includes(scope)).join(' '); }; const mapOAuth2GrantType = ( grantType: keyof OpenAPIV3.OAuth2SecurityScheme['flows'], ) => { const types = { [OAUTH_FLOWS.AUTHORIZATION_CODE]: 'authorization_code', [OAUTH_FLOWS.CLIENT_CREDENTIALS]: 'client_credentials', [OAUTH_FLOWS.IMPLICIT]: 'implicit', [OAUTH_FLOWS.PASSWORD]: 'password', }; return types[grantType]; }; const parseOAuth2 = (scheme: OpenAPIV3.OAuth2SecurityScheme, selectedScopes: string[]) => { const flows = Object.keys( scheme.flows, ) as (keyof OpenAPIV3.OAuth2SecurityScheme['flows'])[]; if (!flows.length) { return {}; } const grantType = flows[0]; const flow = scheme.flows[grantType]; if (!flow) { return {}; } const base = { clientId: '{{ oauth2ClientId }}', grantType: mapOAuth2GrantType(grantType), scope: parseOAuth2Scopes(flow, selectedScopes), type: 'oauth2', }; switch (grantType) { case OAUTH_FLOWS.AUTHORIZATION_CODE: return { ...base, clientSecret: '{{ oauth2ClientSecret }}', redirectUrl: '{{ oauth2RedirectUrl }}', accessTokenUrl: (flow as OpenAPIV3.OAuth2SecurityScheme['flows'][typeof OAUTH_FLOWS.AUTHORIZATION_CODE])?.tokenUrl, authorizationUrl: (flow as OpenAPIV3.OAuth2SecurityScheme['flows'][typeof OAUTH_FLOWS.AUTHORIZATION_CODE])?.authorizationUrl, }; case OAUTH_FLOWS.CLIENT_CREDENTIALS: return { ...base, clientSecret: '{{ oauth2ClientSecret }}', accessTokenUrl: (flow as OpenAPIV3.OAuth2SecurityScheme['flows'][typeof OAUTH_FLOWS.CLIENT_CREDENTIALS])?.tokenUrl, }; case OAUTH_FLOWS.IMPLICIT: return { ...base, redirectUrl: '{{ oauth2RedirectUrl }}', authorizationUrl: (flow as OpenAPIV3.OAuth2SecurityScheme['flows'][typeof OAUTH_FLOWS.IMPLICIT])?.authorizationUrl, }; case OAUTH_FLOWS.PASSWORD: return { ...base, clientSecret: '{{ oauth2ClientSecret }}', username: '{{ oauth2Username }}', password: '{{ oauth2Password }}', accessTokenUrl: (flow as OpenAPIV3.OAuth2SecurityScheme['flows'][typeof OAUTH_FLOWS.PASSWORD])?.tokenUrl, }; default: return {}; } }; export const convert: Converter = async rawData => { // Reset requestCounts = {}; // Validate let apiDocument = parseDocument(rawData); if (!apiDocument || !SUPPORTED_OPENAPI_VERSION.test(apiDocument.openapi)) { return null; } try { apiDocument = await SwaggerParser.validate(apiDocument, { dereference: { circular: 'ignore', }, }) as OpenAPIV3.Document; } catch (err) { console.log('[openapi-3] Import file validation failed', err); } // Import const workspace: ImportRequest = { _type: 'workspace', _id: WORKSPACE_ID, parentId: null, name: `${apiDocument.info.title} ${apiDocument.info.version}`, description: apiDocument.info.description || '', // scope is not set because it could be imported for design OR to generate requests }; const baseEnv: ImportRequest = { _type: 'environment', _id: '__BASE_ENVIRONMENT_ID__', parentId: WORKSPACE_ID, name: 'Base environment', data: { base_url: '{{ scheme }}://{{ host }}{{ base_path }}', }, }; const defaultServerUrl = getDefaultServerUrl(apiDocument); const securityVariables = getSecurityEnvVariables( apiDocument.components?.securitySchemes as unknown as OpenAPIV3.SecuritySchemeObject, ); const protocol = defaultServerUrl.protocol || ''; // Base path is pulled out of the URL, and the trailing slash is removed const basePath = (defaultServerUrl.pathname || '').replace(/\/$/, ''); const openapiEnv: ImportRequest = { _type: 'environment', _id: 'env___BASE_ENVIRONMENT_ID___sub', parentId: baseEnv._id, name: 'OpenAPI env', data: { // note: `URL.protocol` returns with trailing `:` (i.e. "https:") scheme: protocol.replace(/:$/, '') || ['http'], base_path: basePath, host: defaultServerUrl.host || '', ...securityVariables, }, }; const endpoints = parseEndpoints(apiDocument); return [ workspace, baseEnv, openapiEnv, ...endpoints, ]; };