insomnia-importers
Version:
Various data importers for Insomnia
776 lines (676 loc) • 22.9 kB
text/typescript
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,
];
};