mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 07:23:51 +01:00
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
import type {
|
|
Context,
|
|
Environment,
|
|
Folder,
|
|
HttpRequest,
|
|
HttpRequestHeader,
|
|
HttpUrlParameter,
|
|
PartialImportResources,
|
|
PluginDefinition,
|
|
Workspace,
|
|
} from '@yaakapp/api';
|
|
import type { ImportPluginResponse } from '@yaakapp/api/lib/plugins/ImporterPlugin';
|
|
|
|
const POSTMAN_2_1_0_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json';
|
|
const POSTMAN_2_0_0_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json';
|
|
const VALID_SCHEMAS = [POSTMAN_2_0_0_SCHEMA, POSTMAN_2_1_0_SCHEMA];
|
|
|
|
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
|
|
|
interface ExportResources {
|
|
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
|
|
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
|
|
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
|
|
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
|
|
}
|
|
|
|
export const plugin: PluginDefinition = {
|
|
importer: {
|
|
name: 'Postman',
|
|
description: 'Import postman collections',
|
|
onImport(_ctx: Context, args: { text: string }) {
|
|
return convertPostman(args.text);
|
|
},
|
|
},
|
|
};
|
|
|
|
export function convertPostman(contents: string): ImportPluginResponse | undefined {
|
|
const root = parseJSONToRecord(contents);
|
|
if (root == null) return;
|
|
|
|
const info = toRecord(root.info);
|
|
const isValidSchema = VALID_SCHEMAS.includes(
|
|
typeof info.schema === 'string' ? info.schema : 'n/a',
|
|
);
|
|
if (!isValidSchema || !Array.isArray(root.item)) {
|
|
return;
|
|
}
|
|
|
|
const globalAuth = importAuth(root.auth);
|
|
|
|
const exportResources: ExportResources = {
|
|
workspaces: [],
|
|
environments: [],
|
|
httpRequests: [],
|
|
folders: [],
|
|
};
|
|
|
|
const rawDescription = info.description;
|
|
const description =
|
|
typeof rawDescription === 'object' && rawDescription != null && 'content' in rawDescription
|
|
? String(rawDescription.content)
|
|
: rawDescription == null
|
|
? undefined
|
|
: String(rawDescription);
|
|
|
|
const workspace: ExportResources['workspaces'][0] = {
|
|
model: 'workspace',
|
|
id: generateId('workspace'),
|
|
name: info.name ? String(info.name) : 'Postman Import',
|
|
description,
|
|
...globalAuth,
|
|
};
|
|
exportResources.workspaces.push(workspace);
|
|
|
|
// Create the base environment
|
|
const environment: ExportResources['environments'][0] = {
|
|
model: 'environment',
|
|
id: generateId('environment'),
|
|
name: 'Global Variables',
|
|
workspaceId: workspace.id,
|
|
parentModel: 'workspace',
|
|
parentId: null,
|
|
variables:
|
|
toArray<{ key: string; value: string }>(root.variable).map((v) => ({
|
|
name: v.key,
|
|
value: v.value,
|
|
})) ?? [],
|
|
};
|
|
exportResources.environments.push(environment);
|
|
|
|
let sortPriorityIndex = 0;
|
|
const importItem = (v: Record<string, unknown>, folderId: string | null = null) => {
|
|
if (typeof v.name === 'string' && Array.isArray(v.item)) {
|
|
const folder: ExportResources['folders'][0] = {
|
|
model: 'folder',
|
|
sortPriority: sortPriorityIndex++,
|
|
workspaceId: workspace.id,
|
|
id: generateId('folder'),
|
|
name: v.name,
|
|
folderId,
|
|
};
|
|
exportResources.folders.push(folder);
|
|
for (const child of v.item) {
|
|
importItem(child, folder.id);
|
|
}
|
|
} else if (typeof v.name === 'string' && 'request' in v) {
|
|
const r = toRecord(v.request);
|
|
const bodyPatch = importBody(r.body);
|
|
const requestAuth = importAuth(r.auth);
|
|
|
|
const headers: HttpRequestHeader[] = toArray<{
|
|
key: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
}>(r.header).map((h) => {
|
|
return {
|
|
name: h.key,
|
|
value: h.value,
|
|
enabled: !h.disabled,
|
|
};
|
|
});
|
|
|
|
// Add body headers only if they don't already exist
|
|
for (const bodyPatchHeader of bodyPatch.headers) {
|
|
const existingHeader = headers.find(
|
|
(h) => h.name.toLowerCase() === bodyPatchHeader.name.toLowerCase(),
|
|
);
|
|
if (existingHeader) {
|
|
continue;
|
|
}
|
|
headers.push(bodyPatchHeader);
|
|
}
|
|
|
|
const { url, urlParameters } = convertUrl(r.url);
|
|
|
|
const request: ExportResources['httpRequests'][0] = {
|
|
model: 'http_request',
|
|
id: generateId('http_request'),
|
|
workspaceId: workspace.id,
|
|
folderId,
|
|
name: v.name,
|
|
description: r.description ? String(r.description) : undefined,
|
|
method: typeof r.method === 'string' ? r.method : 'GET',
|
|
url,
|
|
urlParameters,
|
|
body: bodyPatch.body,
|
|
bodyType: bodyPatch.bodyType,
|
|
sortPriority: sortPriorityIndex++,
|
|
headers,
|
|
...requestAuth,
|
|
};
|
|
exportResources.httpRequests.push(request);
|
|
} else {
|
|
console.log('Unknown item', v, folderId);
|
|
}
|
|
};
|
|
|
|
for (const item of root.item) {
|
|
importItem(item);
|
|
}
|
|
|
|
const resources = deleteUndefinedAttrs(
|
|
convertTemplateSyntax(exportResources),
|
|
) as PartialImportResources;
|
|
|
|
return { resources };
|
|
}
|
|
|
|
function convertUrl(rawUrl: string | unknown): Pick<HttpRequest, 'url' | 'urlParameters'> {
|
|
if (typeof rawUrl === 'string') {
|
|
return { url: rawUrl, urlParameters: [] };
|
|
}
|
|
|
|
const url = toRecord(rawUrl);
|
|
|
|
let v = '';
|
|
|
|
if ('protocol' in url && typeof url.protocol === 'string') {
|
|
v += `${url.protocol}://`;
|
|
}
|
|
|
|
if ('host' in url) {
|
|
v += `${Array.isArray(url.host) ? url.host.join('.') : url.host}`;
|
|
}
|
|
|
|
if ('port' in url && typeof url.port === 'string') {
|
|
v += `:${url.port}`;
|
|
}
|
|
|
|
if ('path' in url && Array.isArray(url.path) && url.path.length > 0) {
|
|
v += `/${Array.isArray(url.path) ? url.path.join('/') : url.path}`;
|
|
}
|
|
|
|
const params: HttpUrlParameter[] = [];
|
|
if ('query' in url && Array.isArray(url.query) && url.query.length > 0) {
|
|
for (const query of url.query) {
|
|
params.push({
|
|
name: query.key ?? '',
|
|
value: query.value ?? '',
|
|
enabled: !query.disabled,
|
|
});
|
|
}
|
|
}
|
|
|
|
if ('variable' in url && Array.isArray(url.variable) && url.variable.length > 0) {
|
|
for (const v of url.variable) {
|
|
params.push({
|
|
name: `:${v.key ?? ''}`,
|
|
value: v.value ?? '',
|
|
enabled: !v.disabled,
|
|
});
|
|
}
|
|
}
|
|
|
|
if ('hash' in url && typeof url.hash === 'string') {
|
|
v += `#${url.hash}`;
|
|
}
|
|
|
|
// TODO: Implement url.variables (path variables)
|
|
|
|
return { url: v, urlParameters: params };
|
|
}
|
|
|
|
function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'authenticationType'> {
|
|
const auth = toRecord<Record<string, string>>(rawAuth);
|
|
|
|
// Helper: Postman stores auth params as an array of { key, value, ... }
|
|
const pmArrayToObj = (v: unknown): Record<string, unknown> => {
|
|
if (!Array.isArray(v)) return toRecord(v);
|
|
const o: Record<string, unknown> = {};
|
|
for (const i of v) {
|
|
const ii = toRecord(i);
|
|
if (typeof ii.key === 'string') {
|
|
o[ii.key] = ii.value;
|
|
}
|
|
}
|
|
return o;
|
|
};
|
|
|
|
const authType: string | undefined = auth.type ? String(auth.type) : undefined;
|
|
|
|
if (authType === 'noauth') {
|
|
return {
|
|
authenticationType: 'none',
|
|
authentication: {},
|
|
};
|
|
}
|
|
|
|
if ('basic' in auth && authType === 'basic') {
|
|
const b = pmArrayToObj(auth.basic);
|
|
return {
|
|
authenticationType: 'basic',
|
|
authentication: {
|
|
username: String(b.username ?? ''),
|
|
password: String(b.password ?? ''),
|
|
},
|
|
};
|
|
}
|
|
|
|
if ('bearer' in auth && authType === 'bearer') {
|
|
const b = pmArrayToObj(auth.bearer);
|
|
// Postman uses key "token"
|
|
return {
|
|
authenticationType: 'bearer',
|
|
authentication: {
|
|
token: String(b.token ?? ''),
|
|
},
|
|
};
|
|
}
|
|
|
|
if ('awsv4' in auth && authType === 'awsv4') {
|
|
const a = pmArrayToObj(auth.awsv4);
|
|
return {
|
|
authenticationType: 'awsv4',
|
|
authentication: {
|
|
accessKeyId: a.accessKey != null ? String(a.accessKey) : undefined,
|
|
secretAccessKey: a.secretKey != null ? String(a.secretKey) : undefined,
|
|
sessionToken: a.sessionToken != null ? String(a.sessionToken) : undefined,
|
|
region: a.region != null ? String(a.region) : undefined,
|
|
service: a.service != null ? String(a.service) : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
if ('apikey' in auth && authType === 'apikey') {
|
|
const a = pmArrayToObj(auth.apikey);
|
|
return {
|
|
authenticationType: 'apikey',
|
|
authentication: {
|
|
location: a.in === 'query' ? 'query' : 'header',
|
|
key: a.value != null ? String(a.value) : undefined,
|
|
value: a.key != null ? String(a.key) : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
if ('jwt' in auth && authType === 'jwt') {
|
|
const a = pmArrayToObj(auth.jwt);
|
|
return {
|
|
authenticationType: 'jwt',
|
|
authentication: {
|
|
algorithm: a.algorithm != null ? String(a.algorithm).toUpperCase() : undefined,
|
|
secret: a.secret != null ? String(a.secret) : undefined,
|
|
secretBase64: !!a.isSecretBase64Encoded,
|
|
payload: a.payload != null ? String(a.payload) : undefined,
|
|
headerPrefix: a.headerPrefix != null ? String(a.headerPrefix) : undefined,
|
|
location: a.addTokenTo === 'header' ? 'header' : 'query',
|
|
},
|
|
};
|
|
}
|
|
|
|
if ('oauth2' in auth && authType === 'oauth2') {
|
|
const o = pmArrayToObj(auth.oauth2);
|
|
|
|
let grantType = o.grant_type ? String(o.grant_type) : 'authorization_code';
|
|
let pkcePatch: Record<string, unknown> = {};
|
|
|
|
if (grantType === 'authorization_code_with_pkce') {
|
|
grantType = 'authorization_code';
|
|
pkcePatch =
|
|
o.grant_type === 'authorization_code_with_pkce'
|
|
? {
|
|
usePkce: true,
|
|
pkceChallengeMethod: o.challengeAlgorithm ?? undefined,
|
|
pkceCodeVerifier: o.code_verifier != null ? String(o.code_verifier) : undefined,
|
|
}
|
|
: {};
|
|
} else if (grantType === 'password_credentials') {
|
|
grantType = 'password';
|
|
}
|
|
|
|
const accessTokenUrl = o.accessTokenUrl != null ? String(o.accessTokenUrl) : undefined;
|
|
const audience = o.audience != null ? String(o.audience) : undefined;
|
|
const authorizationUrl = o.authUrl != null ? String(o.authUrl) : undefined;
|
|
const clientId = o.clientId != null ? String(o.clientId) : undefined;
|
|
const clientSecret = o.clientSecret != null ? String(o.clientSecret) : undefined;
|
|
const credentials = o.client_authentication === 'body' ? 'body' : undefined;
|
|
const headerPrefix = o.headerPrefix ?? 'Bearer';
|
|
const password = o.password != null ? String(o.password) : undefined;
|
|
const redirectUri = o.redirect_uri != null ? String(o.redirect_uri) : undefined;
|
|
const scope = o.scope != null ? String(o.scope) : undefined;
|
|
const state = o.state != null ? String(o.state) : undefined;
|
|
const username = o.username != null ? String(o.username) : undefined;
|
|
|
|
let grantPatch: Record<string, unknown> = {};
|
|
if (grantType === 'authorization_code') {
|
|
grantPatch = {
|
|
clientSecret,
|
|
authorizationUrl,
|
|
accessTokenUrl,
|
|
redirectUri,
|
|
state,
|
|
...pkcePatch,
|
|
};
|
|
} else if (grantType === 'implicit') {
|
|
grantPatch = { authorizationUrl, redirectUri, state };
|
|
} else if (grantType === 'password') {
|
|
grantPatch = { clientSecret, accessTokenUrl, username, password };
|
|
} else if (grantType === 'client_credentials') {
|
|
grantPatch = { clientSecret, accessTokenUrl };
|
|
}
|
|
|
|
const authentication = {
|
|
name: 'oauth2',
|
|
grantType,
|
|
audience,
|
|
clientId,
|
|
credentials,
|
|
headerPrefix,
|
|
scope,
|
|
...grantPatch,
|
|
} as Record<string, unknown>;
|
|
|
|
return { authenticationType: 'oauth2', authentication };
|
|
}
|
|
|
|
return { authenticationType: null, authentication: {} };
|
|
}
|
|
|
|
function importBody(rawBody: unknown): Pick<HttpRequest, 'body' | 'bodyType' | 'headers'> {
|
|
const body = toRecord(rawBody) as {
|
|
mode: string;
|
|
graphql: { query?: string; variables?: string };
|
|
urlencoded?: { key?: string; value?: string; disabled?: boolean }[];
|
|
formdata?: {
|
|
key?: string;
|
|
value?: string;
|
|
disabled?: boolean;
|
|
contentType?: string;
|
|
src?: string;
|
|
}[];
|
|
raw?: string;
|
|
options?: { raw?: { language?: string } };
|
|
file?: { src?: string };
|
|
};
|
|
if (body.mode === 'graphql') {
|
|
return {
|
|
headers: [
|
|
{
|
|
name: 'Content-Type',
|
|
value: 'application/json',
|
|
enabled: true,
|
|
},
|
|
],
|
|
bodyType: 'graphql',
|
|
body: {
|
|
text: JSON.stringify(
|
|
{
|
|
query: body.graphql?.query || '',
|
|
variables: parseJSONToRecord(body.graphql?.variables || '{}'),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
if (body.mode === 'urlencoded') {
|
|
return {
|
|
headers: [
|
|
{
|
|
name: 'Content-Type',
|
|
value: 'application/x-www-form-urlencoded',
|
|
enabled: true,
|
|
},
|
|
],
|
|
bodyType: 'application/x-www-form-urlencoded',
|
|
body: {
|
|
form: toArray<NonNullable<typeof body.urlencoded>[0]>(body.urlencoded).map((f) => ({
|
|
enabled: !f.disabled,
|
|
name: f.key ?? '',
|
|
value: f.value ?? '',
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
if (body.mode === 'formdata') {
|
|
return {
|
|
headers: [
|
|
{
|
|
name: 'Content-Type',
|
|
value: 'multipart/form-data',
|
|
enabled: true,
|
|
},
|
|
],
|
|
bodyType: 'multipart/form-data',
|
|
body: {
|
|
form: toArray<NonNullable<typeof body.formdata>[0]>(body.formdata).map((f) =>
|
|
f.src != null
|
|
? {
|
|
enabled: !f.disabled,
|
|
contentType: f.contentType ?? null,
|
|
name: f.key ?? '',
|
|
file: f.src ?? '',
|
|
}
|
|
: {
|
|
enabled: !f.disabled,
|
|
name: f.key ?? '',
|
|
value: f.value ?? '',
|
|
},
|
|
),
|
|
},
|
|
};
|
|
}
|
|
if (body.mode === 'raw') {
|
|
return {
|
|
headers: [
|
|
{
|
|
name: 'Content-Type',
|
|
value: body.options?.raw?.language === 'json' ? 'application/json' : '',
|
|
enabled: true,
|
|
},
|
|
],
|
|
bodyType: body.options?.raw?.language === 'json' ? 'application/json' : 'other',
|
|
body: {
|
|
text: body.raw ?? '',
|
|
},
|
|
};
|
|
}
|
|
if (body.mode === 'file') {
|
|
return {
|
|
headers: [],
|
|
bodyType: 'binary',
|
|
body: {
|
|
filePath: body.file?.src,
|
|
},
|
|
};
|
|
}
|
|
return { headers: [], bodyType: null, body: {} };
|
|
}
|
|
|
|
function parseJSONToRecord<T>(jsonStr: string): Record<string, T> | null {
|
|
try {
|
|
return toRecord(JSON.parse(jsonStr));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function toRecord<T>(value: Record<string, T> | unknown): Record<string, T> {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, T>;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function toArray<T>(value: unknown): T[] {
|
|
if (Object.prototype.toString.call(value) === '[object Array]') return value as T[];
|
|
return [];
|
|
}
|
|
|
|
/** Recursively render all nested object properties */
|
|
function convertTemplateSyntax<T>(obj: T): T {
|
|
if (typeof obj === 'string') {
|
|
return obj.replace(
|
|
/{{\s*(_\.)?([^}]*)\s*}}/g,
|
|
(_m, _dot, expr) => `\${[${expr.trim().replace(/^vault:/, '')}]}`,
|
|
) as T;
|
|
}
|
|
if (Array.isArray(obj) && obj != null) {
|
|
return obj.map(convertTemplateSyntax) as T;
|
|
}
|
|
if (typeof obj === 'object' && obj != null) {
|
|
return Object.fromEntries(
|
|
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
|
|
) as T;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
function deleteUndefinedAttrs<T>(obj: T): T {
|
|
if (Array.isArray(obj) && obj != null) {
|
|
return obj.map(deleteUndefinedAttrs) as T;
|
|
}
|
|
if (typeof obj === 'object' && obj != null) {
|
|
return Object.fromEntries(
|
|
Object.entries(obj)
|
|
.filter(([, v]) => v !== undefined)
|
|
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
|
|
) as T;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
const idCount: Partial<Record<string, number>> = {};
|
|
|
|
function generateId(model: string): string {
|
|
idCount[model] = (idCount[model] ?? -1) + 1;
|
|
return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;
|
|
}
|