/* eslint-disable no-param-reassign */ /* eslint-disable max-len */ import fs from 'fs' import path from 'path' import ts from 'typescript' import { getConfigFile, tickLog } from '../helpers' import { ConfigFile, IndexState } from '../types' const apiFolder = './API/' const pkgPath = './package.json' const idxPath = './API/index.ts' export const CreateDocumentation = async (state: IndexState, level: 'Internal' | 'Public') => { const { serviceDescription }: ConfigFile = await getConfigFile() // Helpers const readDirs = (dir: string): string[] => fs.readdirSync(dir).filter( (f) => fs.statSync(path.join(dir, f)).isDirectory(), ) const getReadme = (dir: string): string => { const p = path.join(dir, 'README.md') return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8').trim() : '' } const getPkgInfo = () => { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) return { title: pkg.name, description: pkg.description, version: pkg.version } } const loadPublicEndpoints = (): Set => { const src = fs.readFileSync(idxPath, 'utf-8') const sf = ts.createSourceFile(idxPath, src, ts.ScriptTarget.Latest, true) const set = new Set() sf.forEachChild((node) => { if ( ts.isExpressionStatement(node) && ts.isBinaryExpression(node.expression) && node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.expression.left) && ts.isIdentifier(node.expression.left.expression) && node.expression.left.expression.text === 'exports' && node.expression.left.name.text === 'PublicApiEndpoint' && ts.isObjectLiteralExpression(node.expression.right) ) { node.expression.right.properties.forEach((p) => { if ( ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.initializer.kind === ts.SyntaxKind.TrueKeyword ) { set.add(p.name.text) } }) } }) return set } // Convert TS AST types to JSON Schema const tsTypeToSchema = (node: ts.TypeNode, sf: ts.SourceFile): any => { switch (node.kind) { case ts.SyntaxKind.StringKeyword: return { type: 'string' } case ts.SyntaxKind.NumberKeyword: return { type: 'number' } case ts.SyntaxKind.BooleanKeyword: return { type: 'boolean' } case ts.SyntaxKind.AnyKeyword: return {} case ts.SyntaxKind.LiteralType: { const lit = node as ts.LiteralTypeNode if (lit.literal.kind === ts.SyntaxKind.TrueKeyword) return { type: 'boolean', enum: [true] } if (lit.literal.kind === ts.SyntaxKind.FalseKeyword) return { type: 'boolean', enum: [false] } if (ts.isStringLiteral(lit.literal)) return { type: 'string', enum: [lit.literal.text] } if (ts.isNumericLiteral(lit.literal)) return { type: 'number', enum: [Number(lit.literal.text)] } return {} } // This is intentionally left as-is. It creates a placeholder we can find and replace later. case ts.SyntaxKind.TypeReference: { const tr = node as ts.TypeReferenceNode const name = ts.isIdentifier(tr.typeName) ? tr.typeName.text : tr.typeName.getText(sf) return { type: 'string', description: `(${name})` } } case ts.SyntaxKind.UnionType: { const ut = node as ts.UnionTypeNode const members = ut.types.map((t) => tsTypeToSchema(t, sf)) const enums = members.filter((m) => m.enum).flatMap((m) => m.enum) if (enums.length) return { type: typeof enums[0], enum: enums } const types = members.map((m) => m.type).filter(Boolean) return types.length ? { type: types.length === 1 ? types[0] : types } : {} } case ts.SyntaxKind.ArrayType: { const at = node as ts.ArrayTypeNode return { type: 'array', items: tsTypeToSchema(at.elementType, sf) } } case ts.SyntaxKind.TypeLiteral: { const tl = node as ts.TypeLiteralNode const schema: any = { type: 'object', properties: {}, required: [] } tl.members.forEach((m) => { if (ts.isPropertySignature(m) && m.type && m.name) { const key = (m.name as ts.Identifier).text schema.properties[key] = tsTypeToSchema(m.type, sf) if (!m.questionToken) schema.required.push(key) } }) if (!schema.required.length) delete schema.required return schema } default: return {} } } // Parse interfaces from types.ts const parseInterfaces = (filePath: string): Record => { const content = fs.readFileSync(filePath, 'utf-8') const sf = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true) const schemas: Record = {} sf.forEachChild((node) => { if (ts.isInterfaceDeclaration(node)) { const name = node.name.text const schema: any = { type: 'object', properties: {}, required: [] } node.members.forEach((m) => { if (ts.isPropertySignature(m) && m.type && m.name) { const key = (m.name as ts.Identifier).text schema.properties[key] = tsTypeToSchema(m.type, sf) if (!m.questionToken) schema.required.push(key) } }) if (!schema.required.length) delete schema.required schemas[name] = schema } }) return schemas } // Recursively traverses a JSON schema and replaces placeholder properties const resolveRefsInSchema = (schema: any, localSchemas: Record) => { if (typeof schema !== 'object' || schema === null) return if (schema.type === 'string' && schema.description?.startsWith('(') && schema.description?.endsWith(')')) { const typeName = schema.description.slice(1, -1) if (localSchemas[typeName]) { delete schema.type delete schema.description schema.$ref = `#/components/schemas/${localSchemas[typeName]}` return } } if (schema.properties) { Object.values(schema.properties).forEach((prop: any) => resolveRefsInSchema(prop, localSchemas)) } if (schema.items) { resolveRefsInSchema(schema.items, localSchemas) } } // ----- NEW HELPER FUNCTION TO ADD DEFAULTS ----- /** * Recursively walks a schema and an example object in parallel, adding 'default' * values from the example to the schema. It correctly handles $ref pointers. * @param schema The schema object to modify. * @param example The corresponding example data for this part of the schema. * @param spec The full OpenAPI spec, needed to resolve $ref pointers. */ const addDefaultsFromExample = (schema: any, example: any, spec: any) => { if (!schema || example === undefined) return // If the schema is a reference, find the actual schema and process that instead. if (schema.$ref) { const refPath = schema.$ref.replace('#/components/schemas/', '') const actualSchema = spec.components.schemas[refPath] addDefaultsFromExample(actualSchema, example, spec) return // Stop processing the $ref object itself. } // If it's an object, iterate through its properties. if (schema.type === 'object' && schema.properties) { Object.keys(schema.properties).forEach((key) => { const propSchema = schema.properties[key] const propExample = example?.[key] if (propExample !== undefined) { // For primitive types, add the 'default' key if it doesn't exist. if (['string', 'number', 'boolean'].includes(propSchema.type) && propSchema.default === undefined) { propSchema.default = propExample } // Recurse into nested schemas. addDefaultsFromExample(propSchema, propExample, spec) } }) } // If it's an array, recurse using the first item of the example array. if (schema.type === 'array' && schema.items && Array.isArray(example) && example.length > 0) { addDefaultsFromExample(schema.items, example[0], spec) } } // Generate example const generateExample = (schema: any, vPath: string): any => { const successDataPath = path.join(vPath, '_data', 'successData.ts') const astNodeToValue = (node: ts.Expression): any => { switch (node.kind) { case ts.SyntaxKind.AsExpression: return astNodeToValue((node as ts.AsExpression).expression) case ts.SyntaxKind.SatisfiesExpression: return astNodeToValue((node as ts.SatisfiesExpression).expression) case ts.SyntaxKind.ObjectLiteralExpression: { const obj: { [key: string]: any } = {}; (node as ts.ObjectLiteralExpression).properties.forEach((prop) => { if (ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) { const key = prop.name.text obj[key] = astNodeToValue(prop.initializer) } }) return obj } case ts.SyntaxKind.ArrayLiteralExpression: return (node as ts.ArrayLiteralExpression).elements.map(astNodeToValue) case ts.SyntaxKind.StringLiteral: case ts.SyntaxKind.NoSubstitutionTemplateLiteral: return (node as ts.StringLiteral).text case ts.SyntaxKind.NumericLiteral: return Number((node as ts.NumericLiteral).text) case ts.SyntaxKind.PrefixUnaryExpression: { const unaryNode = node as ts.PrefixUnaryExpression if (unaryNode.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(unaryNode.operand)) { return -Number(unaryNode.operand.text) } return undefined } case ts.SyntaxKind.TrueKeyword: return true case ts.SyntaxKind.FalseKeyword: return false case ts.SyntaxKind.NullKeyword: return null default: console.warn(`[DocGen] Unhandled AST node kind in docExample: ${ts.SyntaxKind[node.kind]}`) return undefined } } if (fs.existsSync(successDataPath)) { try { const content = fs.readFileSync(successDataPath, 'utf-8') const sourceFile = ts.createSourceFile(successDataPath, content, ts.ScriptTarget.Latest, true) let foundExample: any const findDocExample = (node: ts.Node) => { if (foundExample) return if (ts.isVariableStatement(node)) { node.declarationList.declarations.forEach((decl) => { if (foundExample) return if (ts.isIdentifier(decl.name) && decl.name.text === 'docExample' && decl.initializer) { foundExample = astNodeToValue(decl.initializer) } }) } if (!foundExample) { ts.forEachChild(node, findDocExample) } } findDocExample(sourceFile) if (foundExample) { return foundExample } } catch (e) { console.warn(`Could not parse docExample from ${successDataPath}:`, e) } } const generateFromSchema = (s: any): any => { if (!s || s.$ref) return undefined if (s.enum) return s.enum[0] switch (s.type) { case 'object': { const obj: { [key: string]: any } = {} Object.entries(s.properties || {}).forEach(([key, value]) => { obj[key] = generateFromSchema(value as any) }) return obj } case 'array': return s.items ? [generateFromSchema(s.items)] : [] case 'string': return '' case 'number': return 0 case 'boolean': return false default: return null } } return generateFromSchema(schema) } // Build OpenAPI spec const { title, description, version } = getPkgInfo() const spec: any = { openapi: '3.0.0', info: { title: `${level} ${serviceDescription}`, description, version, }, servers: [ { url: '{{baseUrl}}', description: 'Main API server. The `query` field in the request body determines which endpoint is called.', }, ], paths: {}, components: { schemas: {} }, } const publicSet = loadPublicEndpoints() readDirs(apiFolder).forEach((ns) => { const nsPath = path.join(apiFolder, ns) readDirs(nsPath).forEach((fn) => { const fnPath = path.join(nsPath, fn) readDirs(fnPath).forEach((ver) => { const key = `${ns}_${fn}_${ver}` const endpointName = key.replace(/\./g, '_') if (level === 'Public' && !publicSet.has(endpointName)) { return } const vPath = path.join(fnPath, ver) const desc = getReadme(vPath) const typesFile = path.join(vPath, 'types.ts') const localSchemas: Record = {} if (fs.existsSync(typesFile)) { const content = fs.readFileSync(typesFile, 'utf-8') const hasAuth = content.includes('WappiBaseAuthenticatedBody') const raw = parseInterfaces(typesFile) Object.entries(raw).forEach(([iname, schema]) => { const inameKey = `${key}_${iname}` spec.components.schemas[inameKey] = schema localSchemas[iname] = inameKey }) localSchemas.hasAuth = `${hasAuth}` Object.values(localSchemas).forEach((schemaKey) => { if (typeof schemaKey === 'string') { const schema = spec.components.schemas[schemaKey] if (schema) { resolveRefsInSchema(schema, localSchemas) } } }) } const reqKey = `${key}_Request` spec.components.schemas[reqKey] = { type: 'object', properties: { name: { type: 'string', default: `${ns} - ${fn} : ${ver}` }, version: { type: 'string', default: ver }, query: { type: 'string', default: `${ns}_${fn}` }, data: { $ref: `#/components/schemas/${localSchemas.EndPointBody}` }, results: { type: 'object', properties: { name: { type: 'string', default: 'results' }, }, }, }, required: ['name', 'version', 'query', 'data', 'results'], } const exampleObj: any = { name: `${ns} ${fn}`, version: ver, query: `${ns}_${fn}`, data: generateExample(spec.components.schemas[localSchemas.EndPointBody], vPath), results: { name: 'results', }, } if (localSchemas.hasAuth === 'true') { // Define the 'auth' property schema as a string to match the example. spec.components.schemas[reqKey].properties.auth = { type: 'string', default: '{{AuthKey}}' } spec.components.schemas[reqKey].required.push('auth') // Set the example value. exampleObj.auth = '{{AuthKey}}' } // INJECT DEFAULTS INTO THE SCHEMA const endPointBodySchema = spec.components.schemas[localSchemas.EndPointBody] const endPointBodyExample = exampleObj.data if (endPointBodySchema && endPointBodyExample) { addDefaultsFromExample(endPointBodySchema, endPointBodyExample, spec) } const ep = `/${ns}/${fn}/${ver}` spec.paths[ep] = { post: { summary: `${ns} - ${fn} ${ver}`, description: desc, requestBody: { required: true, content: { 'application/json': { schema: { $ref: `#/components/schemas/${reqKey}` }, example: exampleObj, }, }, }, responses: { '200': { description: 'Success', content: { 'application/json': { schema: { $ref: `#/components/schemas/${localSchemas.SuccessResponse}` }, }, }, }, '400': { description: 'Error', content: { 'application/json': { schema: { $ref: `#/components/schemas/${localSchemas.ErrorResponse}` }, }, }, }, }, }, } }) }) }) // ----- ADD STANDALONE /HEALTHCHECK ENDPOINT ----- const healthcheckSchemaKey = 'Healthcheck_SuccessResponse' spec.components.schemas[healthcheckSchemaKey] = { type: 'object', properties: { complete: { type: 'boolean', default: true }, statusCode: { type: 'number', default: 200 }, message: { type: 'string', default: 'Healthy' }, success: { type: 'boolean', default: true }, }, required: ['complete', 'statusCode', 'message', 'success'], } spec.paths['/healthcheck'] = { get: { summary: 'Health Check', description: 'A simple endpoint to check the health of the API service.', responses: { '200': { description: 'Success', content: { 'application/json': { schema: { $ref: `#/components/schemas/${healthcheckSchemaKey}` }, example: { complete: true, statusCode: 200, message: 'Healthy', success: true, }, }, }, }, }, }, } fs.writeFileSync(`${title}-${level}-openAPI-spec.json`, JSON.stringify(spec, null, 2)) tickLog(`${level} OpenAPI Documentation Complete`, 'green', 'white', state, true) } export default CreateDocumentation