@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
JavaScript
;
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 };
}