/* oxlint-disable no-base-to-string */ 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]; // Both products happen to spell these the same way, but the names are written // out so anything Yaak's oauth1 plugin can't offer as a select option lands on // the default instead of being passed straight through. const OAUTH1_SIGNATURE_METHODS: Record = { "HMAC-SHA1": "HMAC-SHA1", "HMAC-SHA256": "HMAC-SHA256", "HMAC-SHA512": "HMAC-SHA512", "RSA-SHA1": "RSA-SHA1", "RSA-SHA256": "RSA-SHA256", "RSA-SHA512": "RSA-SHA512", PLAINTEXT: "PLAINTEXT", }; const DEFAULT_OAUTH1_SIGNATURE_METHOD = "HMAC-SHA1"; // Postman's dynamic variables that a Yaak template function reproduces exactly. // The faker-backed ones ({{$randomFirstName}} and friends) have no equivalent and // are left alone. Arguments have to be single-quoted; the template parser reads a // double-quoted call as raw text. const POSTMAN_DYNAMIC_VARIABLES: Record = { $guid: "uuid.v4()", $randomUUID: "uuid.v4()", $timestamp: "timestamp.unix()", $isoTimestamp: "timestamp.iso8601()", // Postman's $randomInt is an integer from 0 to 1000 $randomInt: "random.range(min='0',max='1000',decimals='0')", }; type AtLeast = Partial & Pick; interface ExportResources { workspaces: AtLeast[]; environments: AtLeast[]; httpRequests: AtLeast[]; folders: AtLeast[]; } 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 sourceKeys: Record = {}; const trackSourceKey = (modelId: string, v: Record, prefix: string) => { const id = v.id ?? v._postman_id; if (typeof id === "string" && id !== "") sourceKeys[modelId] = `${prefix}:${id}`; }; const exportResources: ExportResources = { workspaces: [], environments: [], httpRequests: [], folders: [], }; const workspace: ExportResources["workspaces"][0] = { model: "workspace", id: generateId("workspace"), name: info.name ? String(info.name) : "Postman Import", description: importDescription(info.description), ...globalAuth, }; trackSourceKey(workspace.id, info, "collection"); 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, 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, ...importAuth(v.auth), }; trackSourceKey(folder.id, v, "item"); 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: importDescription(r.description), method: typeof r.method === "string" ? r.method : "GET", url, urlParameters, body: bodyPatch.body, bodyType: bodyPatch.bodyType, sortPriority: sortPriorityIndex++, headers, ...requestAuth, }; trackSourceKey(request.id, v, "item"); 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, sourceKeys }; } function convertUrl(rawUrl: unknown): Pick { 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(".") : String(url.host)}`; } if ("port" in url && typeof url.port === "string") { v += `:${url.port}`; } if ("path" in url) { if (Array.isArray(url.path) && url.path.length > 0) { v += `/${url.path.join("/")}`; } else if (typeof url.path === "string" && url.path.length > 0) { v += `/${url.path.replace(/^\//, "")}`; } } 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}`; } return { url: v, urlParameters: params }; } function importAuth(rawAuth: unknown): Pick { const auth = toRecord>(rawAuth); // Helper: Postman stores auth params as an array of { key, value, ... } const pmArrayToObj = (v: unknown): Record => { if (!Array.isArray(v)) return toRecord(v); const o: Record = {}; 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 ("digest" in auth && authType === "digest") { const d = pmArrayToObj(auth.digest); return { authenticationType: "digest", authentication: { username: d.username != null ? String(d.username) : undefined, password: d.password != null ? String(d.password) : undefined, realm: d.realm != null ? String(d.realm) : undefined, }, }; } if ("ntlm" in auth && authType === "ntlm") { const n = pmArrayToObj(auth.ntlm); return { authenticationType: "windows", authentication: { username: n.username != null ? String(n.username) : undefined, password: n.password != null ? String(n.password) : undefined, domain: n.domain != null ? String(n.domain) : undefined, workstation: n.workstation != null ? String(n.workstation) : undefined, }, }; } 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", // Postman's "key" is the header/parameter name and its "value" is the // secret, matching Yaak's own apikey auth. key: a.key != null ? String(a.key) : undefined, value: a.value != null ? String(a.value) : 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 ("oauth1" in auth && authType === "oauth1") { const o = pmArrayToObj(auth.oauth1); const signatureMethod = o.signatureMethod != null ? String(o.signatureMethod).toUpperCase() : DEFAULT_OAUTH1_SIGNATURE_METHOD; return { authenticationType: "oauth1", authentication: { signatureMethod: OAUTH1_SIGNATURE_METHODS[signatureMethod] ?? DEFAULT_OAUTH1_SIGNATURE_METHOD, consumerKey: o.consumerKey != null ? String(o.consumerKey) : undefined, consumerSecret: o.consumerSecret != null ? String(o.consumerSecret) : undefined, tokenKey: o.token != null ? String(o.token) : undefined, tokenSecret: o.tokenSecret != null ? String(o.tokenSecret) : undefined, privateKey: o.privateKey != null ? String(o.privateKey) : undefined, callback: o.callback != null ? String(o.callback) : undefined, verifier: o.verifier != null ? String(o.verifier) : undefined, timestamp: o.timestamp != null ? String(o.timestamp) : undefined, nonce: o.nonce != null ? String(o.nonce) : undefined, version: o.version != null ? String(o.version) : undefined, realm: o.realm != null ? String(o.realm) : undefined, }, }; } 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 = {}; 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 = {}; 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; return { authenticationType: "oauth2", authentication }; } return { authenticationType: null, authentication: {} }; } function importBody(rawBody: unknown): Pick { 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[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[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(jsonStr: string): Record | null { try { return toRecord(JSON.parse(jsonStr)); } catch { return null; } } function toRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record; } return {}; } function toArray(value: unknown): T[] { if (Object.prototype.toString.call(value) === "[object Array]") return value as T[]; return []; } function importDescription(rawDescription: unknown): string | undefined { if (rawDescription == null) { return undefined; } if (typeof rawDescription === "string") { return rawDescription; } if (typeof rawDescription === "object" && !Array.isArray(rawDescription)) { const description = toRecord(rawDescription); if ("content" in description && description.content != null) { return String(description.content); } return undefined; } return String(rawDescription); } /** Recursively render all nested object properties */ function convertTemplateSyntax(obj: T): T { if (typeof obj === "string") { return obj.replace(/{{\s*(_\.)?([^}]*)\s*}}/g, (_m, _dot, expr) => { const name = String(expr).trim(); // hasOwn, so a collection using {{constructor}} doesn't reach Object.prototype if (Object.hasOwn(POSTMAN_DYNAMIC_VARIABLES, name)) { return `\${[${POSTMAN_DYNAMIC_VARIABLES[name]}]}`; } return `\${[${name.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(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> = {}; function generateId(model: string): string { idCount[model] = (idCount[model] ?? -1) + 1; return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`; }