mfdoc
Version:
Auto generate JS SDK and HTTP API documentation
387 lines • 15 kB
JavaScript
import assert from 'assert';
import { execSync } from 'child_process';
import fse from 'fs-extra';
import { compact, forEach, last, nth, set, upperFirst } from 'lodash-es';
import path from 'path';
import { isObjectEmpty, pathSplit } from 'softkave-js-utils';
import { Doc } from './doc.js';
import { getEndpointsFromSrcPath } from './getEndpointsFromSrcPath.js';
import { isMfdocFieldArray, isMfdocFieldBinary, isMfdocFieldBoolean, isMfdocFieldDate, isMfdocFieldNull, isMfdocFieldNumber, isMfdocFieldObject, isMfdocFieldOrCombination, isMfdocFieldString, isMfdocFieldUndefined, isMfdocMultipartFormdata, isMfdocSdkParamsBody, mfdocConstruct, objectHasRequiredFields, } from './mfdoc.js';
import { filterEndpointsByTags, hasPackageJson } from './utils.js';
function getEnumType(doc, item) {
const name = item.enumName;
if (name && doc.generatedTypeCache.has(name)) {
return name;
}
const text = item.valid?.map(next => `"${next}"`).join(' | ') ?? 'string';
if (name) {
doc.generatedTypeCache.set(name, true);
doc.appendType(`export type ${name} = ${text}`);
return name;
}
return text;
}
function getStringType(doc, item) {
return item.valid?.length ? getEnumType(doc, item) : 'string';
}
function getNumberType(item) {
return 'number';
}
function getBooleanType(item) {
return 'boolean';
}
function getNullType(item) {
return 'null';
}
function getUndefinedType(item) {
return 'undefined';
}
function getDateType(item) {
return 'number';
}
function getArrayType(doc, item) {
const ofType = item.type;
const typeString = getType(doc, ofType,
/** asFetchResponseIfFieldBinary */ false);
return `Array<${typeString}>`;
}
function getOrCombinationType(doc, item) {
return item.types
.map(next => getType(doc, next, /** asFetchResponseIfFieldBinary */ false))
.join(' | ');
}
function getBinaryType(doc, item, asFetchResponse) {
if (asFetchResponse) {
doc.appendTypeImport(['Readable'], 'stream');
return 'Blob | Readable';
}
else {
doc.appendTypeImport(['Readable'], 'stream');
return 'string | Readable | Blob | Buffer';
}
}
function getType(doc, item, asFetchResponseIfFieldBinary) {
if (isMfdocFieldString(item)) {
return getStringType(doc, item);
}
else if (isMfdocFieldNumber(item)) {
return getNumberType(item);
}
else if (isMfdocFieldBoolean(item)) {
return getBooleanType(item);
}
else if (isMfdocFieldNull(item)) {
return getNullType(item);
}
else if (isMfdocFieldUndefined(item)) {
return getUndefinedType(item);
}
else if (isMfdocFieldDate(item)) {
return getDateType(item);
}
else if (isMfdocFieldArray(item)) {
return getArrayType(doc, item);
}
else if (isMfdocFieldOrCombination(item)) {
return getOrCombinationType(doc, item);
}
else if (isMfdocFieldBinary(item)) {
return getBinaryType(doc, item, asFetchResponseIfFieldBinary);
}
else if (isMfdocFieldObject(item)) {
return generateObjectDefinition(doc, item, asFetchResponseIfFieldBinary);
}
else {
return 'unknown';
}
}
function shouldEncloseObjectKeyInQuotes(key) {
return /[0-9]/.test(key[0]) || /[^A-Za-z0-9]/.test(key);
}
function generateObjectDefinition(doc, item, asFetchResponse, name, extraFields = []) {
name = name ?? item.name;
if (doc.generatedTypeCache.has(name)) {
return name;
}
const fields = item.fields ?? {};
const entries = [];
for (let key in fields) {
const value = fields[key];
const entryType = getType(doc, value.data, asFetchResponse);
const separator = value.required ? ':' : '?:';
key = shouldEncloseObjectKeyInQuotes(key) ? `"${key}"` : key;
const entry = `${key}${separator} ${entryType};`;
entries.push(entry);
const valueData = value.data;
if (isMfdocFieldObject(valueData)) {
generateObjectDefinition(doc, valueData, asFetchResponse);
}
else if (isMfdocFieldArray(valueData) &&
isMfdocFieldObject(valueData.type)) {
generateObjectDefinition(doc, valueData.type, asFetchResponse);
}
}
doc.appendType(`export type ${name} = {`);
entries.concat(extraFields).forEach(entry => doc.appendType(entry));
doc.appendType('}');
doc.generatedTypeCache.set(name, true);
return name;
}
function getTypesFromEndpoint(endpoint) {
// Request body
const sdkRequestBodyRaw = endpoint.sdkParamsBody ?? endpoint.requestBody;
const sdkRequestObject = isMfdocFieldObject(sdkRequestBodyRaw)
? sdkRequestBodyRaw
: isMfdocMultipartFormdata(sdkRequestBodyRaw)
? sdkRequestBodyRaw.items
: isMfdocSdkParamsBody(sdkRequestBodyRaw)
? sdkRequestBodyRaw.def
: undefined;
// Success response body
const successResponseBodyRaw = endpoint.responseBody;
const successResponseBodyObject = isMfdocFieldObject(successResponseBodyRaw)
? successResponseBodyRaw
: undefined;
// Success response headers
const successResponseHeadersObject = endpoint.responseHeaders;
const successObjectFields = {};
const requestBodyObjectHasRequiredFields = sdkRequestObject && objectHasRequiredFields(sdkRequestObject);
if (successResponseBodyObject) {
if (objectHasRequiredFields(successResponseBodyObject))
successObjectFields.body = mfdocConstruct.constructObjectField({
required: true,
data: successResponseBodyObject,
});
else
successObjectFields.body = mfdocConstruct.constructObjectField({
required: false,
data: successResponseBodyObject,
});
}
else if (isMfdocFieldBinary(successResponseBodyRaw)) {
successObjectFields.body = mfdocConstruct.constructObjectField({
required: true,
data: successResponseBodyRaw,
});
}
return {
sdkRequestBodyRaw,
sdkRequestObject,
successResponseBodyRaw,
successResponseBodyObject,
successResponseHeadersObject,
requestBodyObjectHasRequiredFields,
};
}
function generateTypesFromEndpoint(doc, endpoint) {
const { sdkRequestObject: requestBodyObject, successResponseBodyObject } = getTypesFromEndpoint(endpoint);
// Request body
if (requestBodyObject) {
generateObjectDefinition(doc, requestBodyObject, false);
}
// Success response body
if (successResponseBodyObject) {
generateObjectDefinition(doc, successResponseBodyObject,
/** asFetchResponse */ true);
}
}
function documentTypesFromEndpoint(doc, endpoint) {
generateTypesFromEndpoint(doc, endpoint);
}
function decideIsBinaryRequest(req) {
return (isMfdocMultipartFormdata(req) ||
(isMfdocSdkParamsBody(req) && req.serializeAs === 'formdata'));
}
function generateEndpointCode(doc, types, className, fnName, endpoint) {
const { sdkRequestObject, successResponseBodyRaw, successResponseBodyObject, sdkRequestBodyRaw: requestBodyRaw, requestBodyObjectHasRequiredFields, } = types;
doc.appendImportFromGenTypes(compact([sdkRequestObject?.name, successResponseBodyObject?.name]));
let param0 = '';
let resultType = 'void';
let templateParams = '';
let param1 = 'opts?: MfdocEndpointOpts';
const isBinaryRequest = decideIsBinaryRequest(requestBodyRaw);
const isBinaryResponse = isMfdocFieldBinary(successResponseBodyRaw);
const requestBodyObjectName = sdkRequestObject?.name;
if (successResponseBodyObject) {
doc.appendImportFromGenTypes([successResponseBodyObject.name]);
resultType = successResponseBodyObject.name;
}
else if (isBinaryResponse) {
resultType = 'MfdocEndpointResultWithBinaryResponse<TResponseType>';
}
if (sdkRequestObject) {
if (requestBodyObjectHasRequiredFields) {
param0 = `props: ${requestBodyObjectName}`;
}
else {
param0 = `props?: ${requestBodyObjectName}`;
}
}
const bodyText = [];
let mapping = '';
const sdkBody = endpoint.sdkParamsBody;
if (isBinaryResponse) {
bodyText.push('responseType: opts.responseType,');
templateParams = "<TResponseType extends 'blob' | 'stream'>";
param1 =
'opts: MfdocEndpointDownloadBinaryOpts<TResponseType> ' +
'= {responseType: "blob"} as MfdocEndpointDownloadBinaryOpts<TResponseType>';
}
if (isBinaryRequest) {
bodyText.push('formdata: props,');
param1 = 'opts?: MfdocEndpointUploadBinaryOpts';
}
else if (sdkRequestObject) {
bodyText.push('data: props,');
}
if (sdkRequestObject && sdkBody) {
forEach(sdkRequestObject.fields ?? {}, (value, key) => {
const mapTo = sdkBody.mappings(key);
if (mapTo) {
const entry = `"${key}": ["${mapTo[0]}", "${String(mapTo[1])}"],`;
mapping += entry;
}
});
if (mapping.length) {
mapping = `{${mapping}}`;
}
}
const params = compact([param0, param1]).join(',');
const text = `${fnName} = async ${templateParams}(${params}): Promise<${resultType}> => {
${mapping.length ? `const mapping = ${mapping} as const` : ''}
return this.execute${isBinaryResponse ? 'Raw' : 'Json'}({
${bodyText.join('')}
path: "${endpoint.basePathname}",
method: "${endpoint.method.toUpperCase()}",
}, opts, ${mapping.length ? 'mapping' : ''});
}`;
doc.appendToClass(text, className, 'MfdocEndpointsBase');
}
function generateEveryEndpointCode(doc, endpoints) {
const leafEndpointsMap = {};
const branchMap = {};
endpoints.forEach(e1 => {
const endpointName = e1.name;
const rest = pathSplit({ input: endpointName }).filter(p => p.length > 0);
assert(rest.length >= 2);
const fnName = last(rest);
const groupName = nth(rest, rest.length - 2);
const className = `${upperFirst(groupName)}Endpoints`;
const types = getTypesFromEndpoint(e1);
const key = `${className}.${fnName}`;
set(leafEndpointsMap, key, { types, endpoint: e1 });
const branches = rest.slice(0, -1);
const branchesKey = branches.join('.');
set(branchMap, branchesKey, {});
});
doc.appendImport([
'MfdocEndpointsBase',
'type MfdocEndpointResultWithBinaryResponse',
'type MfdocEndpointOpts',
'type MfdocEndpointDownloadBinaryOpts',
'type MfdocEndpointUploadBinaryOpts',
], 'mfdoc-js-sdk-base');
for (const groupName in leafEndpointsMap) {
const group = leafEndpointsMap[groupName];
for (const fnName in group) {
const { types, endpoint } = group[fnName];
generateEndpointCode(doc, types, groupName, fnName, endpoint);
}
}
function docBranch(parentName, ownName, branch) {
if (!isObjectEmpty(branch)) {
forEach(branch, (b1, bName) => {
docBranch(ownName, bName, b1);
});
}
if (parentName) {
doc.appendToClass(`${ownName} = new ${upperFirst(ownName)}Endpoints(this.config, this);`, `${upperFirst(parentName)}Endpoints`, 'MfdocEndpointsBase');
}
}
for (const ownName in branchMap) {
docBranch(undefined, ownName, branchMap[ownName]);
}
}
function uniqEnpoints(endpoints) {
const endpointNameMap = {};
endpoints.forEach(e1 => {
const names = pathSplit({ input: e1.basePathname });
const fnName = last(names);
const method = e1.method.toLowerCase();
const key = `${fnName}__${method}`;
endpointNameMap[key] = key;
});
return endpoints.filter(e1 => {
const names = pathSplit({ input: e1.basePathname });
const fnName = last(names);
const method = e1.method.toLowerCase();
const ownKey = `${fnName}__${method}`;
const postKey = `${fnName}__post`;
const getKey = `${fnName}__get`;
if (ownKey === getKey && endpointNameMap[postKey]) {
return false;
}
return true;
});
}
async function addCodeLinesToIndex(params) {
const { indexPath, codeLines } = params;
const indexText = await fse.readFile(indexPath, { encoding: 'utf-8' });
const codeLinesNotFound = codeLines.filter(line => !indexText.includes(line));
if (codeLinesNotFound.length) {
await fse.writeFile(indexPath, indexText + codeLinesNotFound.join('\n'), {
encoding: 'utf-8',
});
}
}
export async function genJsSdk(params) {
const { endpoints, filenamePrefix, tags, outputDir } = params;
assert(await hasPackageJson({ outputPath: outputDir }), 'outputDir must be a valid npm package');
const endpointsDir = path.normalize(outputDir + '/src/endpoints');
const typesFilename = `${filenamePrefix}Types`;
const typesFilenameWithExt = `${typesFilename}.ts`;
const typesFilepath = path.normalize(endpointsDir + '/' + typesFilenameWithExt);
const codesFilename = `${filenamePrefix}Endpoints`;
const codesFilenameWithExt = `${codesFilename}.ts`;
const codesFilepath = path.normalize(endpointsDir + '/' + codesFilenameWithExt);
const typesDoc = new Doc({ genTypesFilepath: `./${typesFilenameWithExt}` });
const codesDoc = new Doc({ genTypesFilepath: `./${typesFilenameWithExt}` });
const httpEndpoints = filterEndpointsByTags(endpoints, tags);
forEach(httpEndpoints, e1 => {
if (e1) {
documentTypesFromEndpoint(typesDoc, e1);
}
});
const uniqHttpEndpoints = uniqEnpoints(httpEndpoints);
generateEveryEndpointCode(codesDoc, uniqHttpEndpoints);
fse.ensureFileSync(typesFilepath);
fse.ensureFileSync(codesFilepath);
await Promise.all([
fse.writeFile(typesFilepath, typesDoc.compileText(), { encoding: 'utf-8' }),
fse.writeFile(codesFilepath, codesDoc.compileText(), { encoding: 'utf-8' }),
]);
await addCodeLinesToIndex({
indexPath: path.normalize(endpointsDir + '/index.ts'),
codeLines: [
`export * from './${typesFilename}.js';`,
`export * from './${codesFilename}.js';`,
],
});
// execSync(`npx code-migration-helpers add-ext -f="${endpointsDir}"`, {
// stdio: 'inherit',
// });
execSync(`cd ${outputDir} && npm run pretty`, {
stdio: 'inherit',
});
}
export async function genJsSdkCmd(params) {
const { srcPath, filenamePrefix, tags, outputPath } = params;
const endpoints = await getEndpointsFromSrcPath({ srcPath });
await genJsSdk({
endpoints,
filenamePrefix,
tags,
outputDir: outputPath,
});
}
//# sourceMappingURL=genJsSdk.js.map