UNPKG

@sirhc77/postman-sdk-gen

Version:

Generate a fully-typed TypeScript SDK from a Postman collection, with support for Axios or Fetch, folder-based namespacing, and auto-inferred types.

441 lines (440 loc) 21.5 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateSdk = generateSdk; exports.extractMethodParts = extractMethodParts; exports.generateAuthArgs = generateAuthArgs; exports.generateFetchMethod = generateFetchMethod; exports.generateAxiosMethod = generateAxiosMethod; exports.generateCode = generateCode; const parser_1 = require("./parser"); const writer_1 = require("./writer"); const utils_1 = require("./utils"); function generateSdk(config) { return __awaiter(this, void 0, void 0, function* () { if (!config.quiet) { console.log(`✨ Generating SDK from: ${config.collection}`); console.log(`📦 Output directory: ${config.output}`); console.log(`⚙️ Using ${config.useFetch ? "fetch" : "axios"}`); } const collection = yield (0, parser_1.loadCollection)(config.collection); const parsedCollection = (0, parser_1.extractEndpoints)(collection); const generatedCode = generateCode(parsedCollection, config); const mainFileContents = config.singleFile ? generatedCode.importCode + generatedCode.typesCode + generatedCode.classCode : generatedCode.importCode + generatedCode.classCode; const indexFileLines = []; const clientFilePath = yield (0, writer_1.writeClientFile)(config.output, `${config.clientName}.ts`, mainFileContents); if (!config.quiet) { console.log(`✅ SDK written to ${clientFilePath}`); indexFileLines.push(`export * from "./${config.clientName}";`); } if (!config.singleFile) { const typesFilePath = yield (0, writer_1.writeClientFile)(config.output, `${config.clientName}Types.d.ts`, generatedCode.typesCode); indexFileLines.push(`export * from "./${config.clientName}Types";`); if (!config.quiet) { console.log(`✅ Types written to ${typesFilePath}`); } } const indexFilePath = yield (0, writer_1.writeClientFile)(config.output, "index.ts", indexFileLines.join("\n")); if (!config.quiet) { console.log(`✅ Index written to ${indexFilePath}`); } }); } function extractMethodParts(endpoint) { const funcName = (0, utils_1.camelCase)(endpoint.name); // e.g. getUserById const namespacePart = endpoint.namespacePath.map(utils_1.capitalize).join(""); const reqTypeName = namespacePart + (0, utils_1.capitalize)(funcName) + "Request"; const resTypeName = namespacePart + (0, utils_1.capitalize)(funcName) + "Response"; const optionsTypeName = namespacePart + (0, utils_1.capitalize)(funcName) + "Options"; const hasBody = endpoint.method !== "GET" && endpoint.requestBody !== undefined; const hasOptions = endpoint.queryParams.length > 0; const hasResponse = !!endpoint.responseSample; const paramMap = new Map(); const optionsMap = new Map(); endpoint.pathParams.forEach(p => paramMap.set(p, (0, utils_1.camelCase)(p))); endpoint.queryParams.forEach(p => optionsMap.set(p, (0, utils_1.camelCase)(p))); const optionsTypeBody = hasOptions ? '{\n\t' + [...optionsMap].map(([_, camel]) => `${camel}?: string`).join(`,\n\t`) + '\n}' : undefined; const optionsType = optionsTypeBody ? "export interface " + optionsTypeName + " " + optionsTypeBody + "\n" : undefined; return { funcName, reqTypeName, resTypeName, optionsTypeName, hasBody, hasOptions, hasResponse, paramMap, optionsMap, optionsType }; } function generateAuthArgs(auth) { switch (auth.type) { case "basic": return ["username: string", "password: string"]; case "bearer": return ["token: string"]; case "apikey": return ["apiAuthKey: string", "apiAuthValue: string", "apiAuthAddTo: 'header' | 'query' = 'header'"]; default: return []; } } function generateFetchMethod(endpoint, collectionAuth) { var _a; const { funcName, reqTypeName, resTypeName, optionsTypeName, hasBody, hasOptions, hasResponse, paramMap, optionsMap, optionsType } = extractMethodParts(endpoint); const args = Array.from(paramMap.entries()).map(([_, camel]) => `${camel}: string`); const authOverride = endpoint.effectiveAuth.type !== collectionAuth.type; if (hasBody) args.push(`body: ${reqTypeName}`); if (authOverride) args.push(...generateAuthArgs(endpoint.effectiveAuth)); if (hasOptions) args.push(`options?: ${optionsTypeName}`); const path = (_a = endpoint.url.path) === null || _a === void 0 ? void 0 : _a.map(pathItem => pathItem.replace(/:([a-zA-Z0-9_]+)(?=\/|$)/g, (_, name) => `\${${(0, utils_1.camelCase)(name)}}`)).join('/'); const pathExpr = `\`${(path === null || path === void 0 ? void 0 : path.startsWith("/")) ? path : "/" + path}\``; const preFetchExpressions = []; preFetchExpressions.push('const headers: Header = {' + '"Content-Type": "application/json"};'); if (endpoint.queryParams.length > 0) { preFetchExpressions.push(`const urlSearchParams = new URLSearchParams();`); preFetchExpressions.push([...optionsMap].map(([name, camel]) => `if (options?.${camel}) { urlSearchParams.set('${name}', options.${camel}); }`).join("\n")); preFetchExpressions.push(`const url = ${pathExpr} + '?' + urlSearchParams.toString();`); } else { if (endpoint.effectiveAuth.type === "apikey") { if (authOverride) { preFetchExpressions.push('const urlSearchParams = (apiAuthAddTo === "query") ? new URLSearchParams() : null;)'); preFetchExpressions.push('if (urlSearchParams) urlSearchParams.set(apiAuthKey, apiAuthValue);'); } else { preFetchExpressions.push('const urlSearchParams = (self.apiAuthAddTo === "query") ? new URLSearchParams() : null;)'); preFetchExpressions.push('if (urlSearchParams) urlSearchParams.set(self.apiAuthKey, self.apiAuthValue);'); } preFetchExpressions.push(`const url = urlSearchParams ? ${pathExpr} + "?" + urlSearchParams.toString() : ${pathExpr};`); } else { preFetchExpressions.push(`const url = ${pathExpr};`); } } if (endpoint.effectiveAuth.type === "apikey") { if (authOverride) { preFetchExpressions.push('if (apiAuthAddTo === "header") headers[apiAuthKey] = apiAuthValue;\n'); } else { preFetchExpressions.push('if (self.apiAuthAddTo === "header") headers[self.apiAuthKey] = self.apiAuthValue;\n'); } } if (endpoint.effectiveAuth.type === "bearer") { if (authOverride) { preFetchExpressions.push('headers.Authorization = "Bearer " + token;'); } else { preFetchExpressions.push('headers.Authorization = "Bearer " + self.token;'); } } if (endpoint.effectiveAuth.type === "basic") { if (authOverride) { preFetchExpressions.push('const auth = isNode ? Buffer.from(username + ":" + password).toString("base64") : btoa(username + ":" + password);'); } else { preFetchExpressions.push('const auth = isNode ? Buffer.from(self.username + ":" + self.password).toString("base64") : btoa(self.username + ":" + self.password);'); } preFetchExpressions.push('headers.Authorization = "Basic " + auth;'); } const bodyJson = hasBody ? jsonToBody(endpoint.requestBody) : undefined; const fetchOpts = [ `\tmethod: "${endpoint.method}"`, `\t\theaders: headers`, hasBody ? `\t\tbody: JSON.stringify(${bodyJson})` : null ].filter(Boolean).join(",\n "); const code = ` async ${funcName}(${args.join(", ")}): Promise<${hasResponse ? resTypeName : 'any'}> { ${preFetchExpressions.join("\n")} const res = await fetch(self.baseUrl + url, { ${fetchOpts} }); return res.json(); }`; const requestType = hasBody ? jsonToType(reqTypeName, endpoint.requestBody) : undefined; const responseType = hasResponse ? jsonToType(resTypeName, endpoint.responseSample) : undefined; return { code, optionsType, requestType, responseType }; } function generateAxiosMethod(endpoint, collectionAuth) { var _a; const { funcName, reqTypeName, resTypeName, optionsTypeName, hasBody, hasOptions, hasResponse, paramMap, optionsType, optionsMap } = extractMethodParts(endpoint); const path = '/' + ((_a = endpoint.url.path) === null || _a === void 0 ? void 0 : _a.map(pathItem => pathItem .replace(/:([a-zA-Z0-9_]+)(?=\/|$)/g, (_, name) => `\${${(0, utils_1.camelCase)(name)}}`)).join('/')); const pathExpr = `\`${path}\``; const authOverride = endpoint.effectiveAuth.type !== collectionAuth.type; const preRequestExpressions = []; preRequestExpressions.push('const params: QueryParams = {};'); preRequestExpressions.push('const headers: Header = {};'); preRequestExpressions.push('headers.Accept = "application/json";'); if (endpoint.queryParams.length > 0) { preRequestExpressions.push([...optionsMap].map(([name, camel]) => `if (options?.${camel}) { params.${name} = options.${camel}; }`).join("\n")); } if (endpoint.effectiveAuth.type === "apikey") { if (authOverride) { preRequestExpressions.push('if (apiAuthAddTo === "query") params[apiAuthKey] = apiAuthValue;'); preRequestExpressions.push('if (apiAuthAddTo === "header") headers[apiAuthKey] = apiAuthValue;'); } else { preRequestExpressions.push('if (self.apiAuthAddTo === "query") params[self.apiAuthKey] = self.apiAuthValue;'); preRequestExpressions.push('if (self.apiAuthAddTo === "header") headers[self.apiAuthKey] = self.apiAuthValue;'); } } if (endpoint.effectiveAuth.type === "bearer") { if (authOverride) { preRequestExpressions.push('headers.Authorization = "Bearer " + token;'); } else { preRequestExpressions.push('headers.Authorization = "Bearer " + self.token;'); } } if (endpoint.effectiveAuth.type === "basic") { if (authOverride) { preRequestExpressions.push('const auth = isNode ? Buffer.from(username + ":" + password).toString("base64") : btoa(username + ":" + password);'); } else { preRequestExpressions.push('const auth = isNode ? Buffer.from(self.username + ":" + self.password).toString("base64") : btoa(self.username + ":" + self.password);'); } preRequestExpressions.push('headers.Authorization = "Basic " + auth;'); } preRequestExpressions.push('const axiosCallOpts: AxiosRequestConfig = {}'); preRequestExpressions.push('if (Object.keys(params).length > 0) axiosCallOpts.params = params;'); preRequestExpressions.push('axiosCallOpts.url = ' + pathExpr); preRequestExpressions.push('axiosCallOpts.method = "' + endpoint.method.toLowerCase() + '"'); preRequestExpressions.push('axiosCallOpts.headers = headers'); const args = [...paramMap.values()].map(p => `${p}: string`); if (hasBody) args.push(`body: ${reqTypeName}`); if (hasOptions) args.push(`options?: ${optionsTypeName}`); const bodyJson = hasBody ? jsonToBody(endpoint.requestBody) : undefined; if (hasBody) preRequestExpressions.push(`axiosCallOpts.data = ${bodyJson};`); const code = ` async ${funcName}(${args.join(", ")}): Promise<${hasResponse ? resTypeName : 'any'}> { ${preRequestExpressions.join("\n")} return self.axiosInstance.request(axiosCallOpts).then(res => res.data); }`; const requestType = hasBody ? jsonToType(reqTypeName, endpoint.requestBody) : undefined; const responseType = hasResponse ? jsonToType(resTypeName, endpoint.responseSample) : undefined; return { code, optionsType, requestType, responseType }; } function jsonToType(name, json) { try { const parsed = JSON.parse(json); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; const lines = Object.entries(parsed).map(([key, value]) => { const camel = (0, utils_1.camelCase)(key); const tsType = inferType(value); return ` ${camel}: ${tsType};`; }); return `export interface ${name} {\n${lines.join("\n")}\n}`; } catch (e) { return undefined; } } function jsonToBody(json) { try { const parsed = JSON.parse(json); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; const lines = Object.entries(parsed).map(([key, _]) => { const camel = (0, utils_1.camelCase)(key); return `\t\t\t\t${key}: body.${camel},`; }); return `{\n${lines.join("\n")}\n}`; } catch (e) { return undefined; } } function inferType(value) { if (value === null) return "any"; switch (typeof value) { case "string": return "string"; case "number": return Number.isInteger(value) ? "number" : "number"; case "boolean": return "boolean"; case "object": if (Array.isArray(value)) { if (value.length === 0) return "any[]"; return `${inferType(value[0])}[]`; } return "{ [key: string]: any }"; default: return "any"; } } function buildEndpointTree(endpoints) { const tree = {}; for (const ep of endpoints) { let current = tree; for (const part of ep.namespacePath) { if (!current[part]) current[part] = {}; current = current[part]; } if (!Array.isArray(current["_endpoints"])) current["_endpoints"] = []; current["_endpoints"].push(ep); } return tree; } function generateNamespaceTypes(config, tree, collectionAuth, path = []) { const members = []; const interfaces = []; if (tree._endpoints) { for (const endpoint of tree._endpoints) { const { optionsType, requestType, responseType } = config.useFetch ? generateFetchMethod(endpoint, collectionAuth) : generateAxiosMethod(endpoint, collectionAuth); const funcName = (0, utils_1.camelCase)(endpoint.name); const args = buildSdkArgs(endpoint, requestType ? (0, utils_1.namespaceToTypeName)(path, funcName).replace("Namespace", "Request") : null, optionsType ? (0, utils_1.namespaceToTypeName)(path, funcName).replace("Namespace", "Options") : null); const returnType = responseType ? (0, utils_1.namespaceToTypeName)(path, funcName).replace("Namespace", "Response") : "any"; members.push(`${funcName}(${args}): Promise<${returnType}>;`); if (requestType) interfaces.push(requestType); if (responseType) interfaces.push(responseType); if (optionsType) interfaces.push(optionsType); } } for (const [key, subtree] of Object.entries(tree)) { if (key === "_endpoints") continue; const childPath = [...path, key]; const { interfaces: childInterfaces, topType } = generateNamespaceTypes(config, subtree, collectionAuth, childPath); interfaces.push(...childInterfaces); members.push(`${key}: ${topType};`); } const currentType = (0, utils_1.namespaceToTypeName)(path); const interfaceBody = `export interface ${currentType} {\n ${members.join("\n ")}\n}`; interfaces.push(interfaceBody); return { interfaces, topType: currentType }; } function buildSdkArgs(endpoint, requestTypeName, optionsTypeName) { const paramMap = new Map(); endpoint.pathParams.forEach(p => paramMap.set(p, (0, utils_1.camelCase)(p))); const paramList = Array.from(paramMap.entries()).map(([_, camel]) => `${camel}: string`); if (requestTypeName) paramList.push(`body: ${requestTypeName}`); if (optionsTypeName) paramList.push(`options?: ${optionsTypeName}`); return paramList.join(", "); } function generateNamespaceObjectForClass(tree, collectionAuth, classProp, config, indent = 2, root = false) { const pad = "\t".repeat(indent); const lines = (root) ? [`this.${classProp} = {`] : [` {`]; for (const [key, value] of Object.entries(tree)) { if (key === "_endpoints") { const methods = value .map(e => config.useFetch ? generateFetchMethod(e, collectionAuth).code : generateAxiosMethod(e, collectionAuth).code) .map(code => code.replace(/^/gm, pad + " ")) .join(",\n\n"); lines.push(methods); continue; } const nested = generateNamespaceObjectForClass(value, collectionAuth, key, config, indent + 1); lines.push(`${key}: ${nested},`); } lines.push(`${pad}}`); return lines.join("\n"); } function generateCode(parsedCollection, config) { const tree = buildEndpointTree(parsedCollection.endpoints); const { interfaces } = generateNamespaceTypes(config, tree, parsedCollection.auth); interfaces.push(`export interface Header { [key: string]: string; }`); if (!config.useFetch) { interfaces.push(`export interface QueryParams { [key: string]: string; }`); } const typeNames = interfaces.map(typeStr => { const match = typeStr.match(/export interface (\w+)/); return match ? match[1] : null; }).filter(Boolean); const constructorArgs = []; const constructorFields = []; const constructorAssignments = []; constructorArgs.push('baseUrl: string'); constructorAssignments.push('const self = this'); constructorAssignments.push('this.baseUrl = baseUrl'); switch (parsedCollection.auth.type) { case "basic": constructorArgs.push('username: string', 'password: string'); constructorFields.push('private readonly username: string', 'private readonly password: string'); constructorAssignments.push('this.username = username', 'this.password = password'); break; case "bearer": constructorArgs.push('token: string'); constructorFields.push('private readonly token: string'); constructorAssignments.push('this.token = token'); break; case "apikey": constructorArgs.push('apiAuthKey: string', 'apiAuthValue: string', 'private apiAuthAddTo: "header" | "query" = "header"'); constructorFields.push('private readonly apiAuthKey: string', 'private readonly apiAuthValue: string', 'private readonly apiAuthAddTo: "header" | "query"'); constructorAssignments.push('this.apiAuthKey = apiAuthKey', 'this.apiAuthValue = apiAuthValue', 'this.apiAuthAddTo = apiAuthAddTo'); break; case "noauth": break; default: throw new Error(`Unsupported auth type: ${parsedCollection.auth.type}`); } if (!config.useFetch) { constructorArgs.push(`axiosConfig?: AxiosRequestConfig`); constructorFields.push(`private readonly axiosInstance: AxiosInstance`); constructorAssignments.push('this.axiosInstance = axios.create({\n' + 'baseURL: this.baseUrl,\n' + '...axiosConfig\n' + '});'); } const constructorBody = Object.keys(tree) .map(ns => generateNamespaceObjectForClass(tree[ns], parsedCollection.auth, ns, config, 0, true)) .join("\n"); const readonlyFields = Object.keys(tree) .map(ns => `readonly ${ns}: ${(0, utils_1.namespaceToTypeName)([ns])};`) .join("\n"); const imports = []; imports.push("import { isNode } from 'browser-or-node';"); if (!config.useFetch) { imports.push("import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';"); } if (!config.singleFile) { imports.push(`import type { ${typeNames.join(", ")} } from './${config.clientName}Types';`); } const importCode = imports.join("\n"); const typesCode = interfaces.join("\n\n"); const classCode = `export class ${config.clientName} { ${readonlyFields} ${constructorFields.join("\n")} private readonly baseUrl: string; constructor(${constructorArgs.join(", ")}) { ${constructorAssignments.join("\n ")} ${constructorBody.replace(/^/gm, " ")} } }`; return { importCode, typesCode, classCode }; }