UNPKG

easy-mcp-server

Version:

AI-era Express replacement with zero-config MCP integration - Build AI-ready APIs in 30 seconds

612 lines (569 loc) 21.4 kB
/** * OpenAPI Helper Utilities * Provides concise functions for building OpenAPI specifications */ const fs = require('fs'); const path = require('path'); /** * Create a query parameter definition * @param {string} name - Parameter name * @param {string|object} schema - Schema type string or object * @param {string} [description] - Parameter description * @param {boolean} [required=false] - Whether parameter is required * @returns {object} OpenAPI parameter object */ function queryParam(name, schema, description, required = false) { const schemaObj = typeof schema === 'string' ? { type: schema } : schema; return { name, in: 'query', description: description || `${name} parameter`, required, schema: schemaObj }; } /** * Create a path parameter definition * @param {string} name - Parameter name * @param {string|object} schema - Schema type string or object * @param {string} [description] - Parameter description * @returns {object} OpenAPI parameter object */ function pathParam(name, schema = 'string', description) { const schemaObj = typeof schema === 'string' ? { type: schema } : schema; return { name, in: 'path', description: description || `${name} parameter`, required: true, schema: schemaObj }; } /** * Create a request body definition * @param {object} schema - JSON schema object * @param {boolean} [required=true] - Whether body is required * @returns {object} OpenAPI requestBody object */ function body(schema, required = true) { return { required, content: { 'application/json': { schema } } }; } /** * Create a response definition * @param {number|string} status - HTTP status code * @param {object} schema - JSON schema object * @param {string} [description] - Response description * @returns {object} OpenAPI response object */ function response(status = 200, schema, description = 'Successful response') { return { [status]: { description, content: { 'application/json': { schema } } } }; } /** * Build a complete OpenAPI spec from simplified parameters * @param {object} options - Specification options * @param {array} [options.query] - Query parameters (array of queryParam results) * @param {array} [options.path] - Path parameters (array of pathParam results) * @param {object} [options.body] - Request body schema (will be wrapped in body()) * @param {boolean} [options.bodyRequired=true] - Whether body is required * @param {object} [options.response] - Response schema (will be wrapped in response()) * @param {number|string} [options.responseStatus=200] - Response status code * @param {string} [options.responseDescription] - Response description * @param {object} [options.responses] - Full responses object (overrides response) * @param {object} [options.extras] - Additional OpenAPI properties * @returns {object} Complete OpenAPI specification object */ function apiSpec({ query = [], path = [], body: bodySchema, bodyRequired = true, response: responseSchema, responseStatus = 200, responseDescription, responses, ...extras }) { const spec = { ...extras }; // Combine all parameters const allParams = [...path, ...query]; if (allParams.length > 0) { spec.parameters = allParams; } // Add request body if (bodySchema) { spec.requestBody = body(bodySchema, bodyRequired); } // Add responses if (responses) { spec.responses = responses; } else if (responseSchema) { spec.responses = response( responseStatus, responseSchema, responseDescription ); } return spec; } module.exports = { queryParam, pathParam, body, response, apiSpec }; /** * Simple JSON Schema inference from an example value. * - Objects: properties inferred recursively, required = all present keys, additionalProperties: false * - Arrays: type array, items inferred from first element if present, else any-type object * - Primitives: type mapped by typeof * - Dates are treated as strings with date-time format when detected * @param {any} example - Example value to infer from * @param {object} [opts] * @param {string} [opts.description] * @returns {object} JSON Schema object with example included */ function schemaFrom(example, opts = {}) { function infer(value) { if (value === null || value === undefined) { return { type: 'null' }; } if (Array.isArray(value)) { if (value.length === 0) { return { type: 'array', items: {} }; } return { type: 'array', items: infer(value[0]) }; } const t = typeof value; if (t === 'string') { // detect ISO date-time const isIso = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value); return isIso ? { type: 'string', format: 'date-time' } : { type: 'string' }; } if (t === 'number') { // Treat integers as integer when safe return Number.isInteger(value) ? { type: 'integer', format: 'int64' } : { type: 'number' }; } if (t === 'boolean') return { type: 'boolean' }; if (t === 'object') { const props = {}; const required = []; for (const key of Object.keys(value)) { props[key] = infer(value[key]); required.push(key); } return { type: 'object', additionalProperties: false, properties: props, required: required.length ? required : undefined }; } return {}; // fallback } const schema = infer(example); if (opts.description) schema.description = opts.description; // Apply field-level descriptions if provided if (opts.fields && schema.type === 'object' && schema.properties) { Object.entries(opts.fields).forEach(([key, fieldMeta]) => { if (schema.properties[key]) { if (fieldMeta && typeof fieldMeta === 'object') { if (fieldMeta.description) schema.properties[key].description = fieldMeta.description; if (fieldMeta.format) schema.properties[key].format = fieldMeta.format; if (fieldMeta.enum) schema.properties[key].enum = fieldMeta.enum; if (fieldMeta.example !== undefined) schema.properties[key].example = fieldMeta.example; } else if (typeof fieldMeta === 'string') { schema.properties[key].description = fieldMeta; } } }); } schema.example = example; return schema; } module.exports.schemaFrom = schemaFrom; /** * Build JSON Schema from a class definition. * Conventions supported: * - static schemaFields: { fieldName: { type, description, format, enum, example, required? } } * - static example(): returns an example object * - static description: optional overall schema description * If schemaFields not present, falls back to schemaFrom(example). * @param {Function} ClassCtor * @returns {object} JSON Schema */ function schemaFromClass(ClassCtor) { try { const fields = ClassCtor.schemaFields; const description = ClassCtor.description; const example = typeof ClassCtor.example === 'function' ? ClassCtor.example() : undefined; if (fields && typeof fields === 'object') { const properties = {}; const required = []; for (const [key, meta] of Object.entries(fields)) { if (!meta || typeof meta !== 'object') continue; const { type, description: desc, format, enum: enm, example: ex, required: isReq } = meta; properties[key] = {}; if (type) properties[key].type = type; if (desc) properties[key].description = desc; if (format) properties[key].format = format; if (enm) properties[key].enum = enm; if (ex !== undefined) properties[key].example = ex; if (isReq !== false) required.push(key); } const schema = { type: 'object', additionalProperties: false, properties }; if (required.length) schema.required = required; if (description) schema.description = description; if (example) schema.example = example; return schema; } // Fallback: infer from example if (example) { return schemaFrom(example, { description }); } // Last resort: empty object schema return { type: 'object', additionalProperties: true, description: description || undefined }; } catch (_) { return { type: 'object', additionalProperties: true }; } } module.exports.schemaFromClass = schemaFromClass; /** * Build JSON Schema from a TypeScript declaration (.d.ts or .ts) by parsing * a class with fields and inline comments. Supports line comments (//) and JSDoc block comments. * Types supported: string, number, integer, boolean, T[], Array<T> (basic inference) * @param {string} filePath - absolute or relative path to the TS file * @param {string} className - class name to extract fields from * @returns {object} JSON Schema */ function schemaFromTsDeclaration(filePath, className, seen = new Set()) { try { const key = `${filePath}::${className}`; if (seen.has(key)) { return { type: 'object', additionalProperties: true }; } seen.add(key); if (!fs.existsSync(filePath)) { return { type: 'object', additionalProperties: true }; } const src = fs.readFileSync(filePath, 'utf8'); // Extract class block const classRegex = new RegExp('(?:/\\*\\*[\\s\\S]*?\\*/|//.*?\\n|\\s)*class\\s+' + className + '\\s*{([\\s\\S]*?)}'); const match = src.match(classRegex); if (!match) { return { type: 'object', additionalProperties: true }; } const body = match[1]; // Helper function to extract @description annotation from a comment line const extractDescriptionAnnotation = (commentText) => { // Match @description('text') or @description('text') format const descMatch = commentText.match(/@description\s*\(\s*['"]([^'"]+)['"]\s*\)/); if (descMatch) { return descMatch[1]; } return null; }; // Optionally extract class-level description (JSDoc, @description annotation, or // immediately above class) let description; const classHeaderRegex = new RegExp('([\\s\\S]*?)class\\s+' + className + '\\s*{'); const headerMatch = src.match(classHeaderRegex); if (headerMatch) { const header = headerMatch[1]; const jsDocMatch = header.match(/\/\*\*([\s\S]*?)\*\//); if (jsDocMatch) { description = jsDocMatch[1].replace(/^[\s*]+/gm, '').trim(); // Check for @description annotation in JSDoc const descAnnotation = extractDescriptionAnnotation(description); if (descAnnotation) { description = descAnnotation; } } else { const lines = header.trimEnd().split(/\n/).reverse(); const descLines = []; for (const line of lines) { const m = line.match(/\s*\/\/\s?(.*)$/); if (m) { const commentText = m[1]; // Check for @description annotation first const descAnnotation = extractDescriptionAnnotation(commentText); if (descAnnotation) { description = descAnnotation; break; } descLines.push(commentText); } else { break; } } if (!description && descLines.length) { description = descLines.reverse().join('\n'); } } } // Parse fields: accumulate preceding comment lines as description const properties = {}; const required = []; let pendingDesc = undefined; const lines = body.split(/\n/); for (let raw of lines) { const line = raw.trim(); if (!line) continue; // Collect JSDoc block start if (line.startsWith('/**')) { const block = [line]; // handled outside; simple accumulation until '*/' // Note: minimalistic; merge into pendingDesc when block closes let content = line; if (!line.includes('*/')) { // multi-line block // no stream parsing here; rely on next lines accumulation below } } // JSDoc single-line or mid-lines const jsDocInline = line.match(/^\*\s?(.*)$/); if (jsDocInline) { const commentText = jsDocInline[1].trim(); // Check for @description annotation first const descAnnotation = extractDescriptionAnnotation(commentText); if (descAnnotation) { pendingDesc = descAnnotation; // Replace any accumulated description with the annotation } else { // Fall back to plain comment text pendingDesc = (pendingDesc ? pendingDesc + '\n' : '') + commentText; } continue; } if (line.startsWith('*/')) { // end of JSDoc, keep pendingDesc as-is continue; } // Line comment - check for @description annotation const lc = line.match(/^\/\/\s?(.*)$/); if (lc) { const commentText = lc[1].trim(); // Check for @description annotation first const descAnnotation = extractDescriptionAnnotation(commentText); if (descAnnotation) { pendingDesc = descAnnotation; // Replace any accumulated description with the annotation } else { // Fall back to plain comment text pendingDesc = (pendingDesc ? pendingDesc + '\n' : '') + commentText; } continue; } // Constructor assignment: this.propertyName = value; (check this BEFORE property match to avoid conflicts) const constructorMatch = raw.match(/^\s*this\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]*?);\s*$/); if (constructorMatch) { const name = constructorMatch[1]; const defaultValue = constructorMatch[2].trim(); // Infer type from default value let tsType = 'string'; // default if (defaultValue === 'true' || defaultValue === 'false') { tsType = 'boolean'; } else if (/^-?\d+(\.\d+)?$/.test(defaultValue)) { tsType = 'number'; } else if (defaultValue === 'undefined' || defaultValue === 'null') { tsType = 'any'; } else if (defaultValue.startsWith('[') || defaultValue.startsWith('{')) { // Array or object literal if (defaultValue.startsWith('[')) { tsType = 'array'; } else { tsType = 'object'; } } else if (defaultValue.startsWith("'") || defaultValue.startsWith('"') || defaultValue.startsWith('`')) { tsType = 'string'; } const schema = tsTypeToJsonSchema(tsType, filePath, seen); if (pendingDesc) schema.description = pendingDesc.trim(); // Parse default value const ex = parseTsDefaultLiteral(defaultValue); if (ex !== undefined) schema.example = ex; properties[name] = schema; // If there's a default value that's not undefined, mark as required const defaultIsUndefined = /(^|\W)undefined(\W|$)/.test(defaultValue); if (!defaultIsUndefined) { required.push(name); } pendingDesc = undefined; continue; } // Property line: name[!]? : type [= default]; use greedy match up to last semicolon on the line const propMatch = raw.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(!)?\s*:?\s*([\s\S]*?);\s*$/); if (propMatch) { const name = propMatch[1]; const bang = !!propMatch[2]; const tsTypeFull = propMatch[3].trim(); // Split type and default by '=' if present let typePart = tsTypeFull; let defaultPart; const eqIdx = tsTypeFull.indexOf('='); if (eqIdx !== -1) { typePart = tsTypeFull.substring(0, eqIdx).trim(); defaultPart = tsTypeFull.substring(eqIdx + 1).trim(); } const tsType = typePart.replace(/\s+as\s+[^\s]+$/, '').trim(); const schema = tsTypeToJsonSchema(tsType, filePath, seen); if (pendingDesc) schema.description = pendingDesc.trim(); if (defaultPart !== undefined) { const ex = parseTsDefaultLiteral(defaultPart); if (ex !== undefined) schema.example = ex; } properties[name] = schema; // Required rule: explicit '!' OR default value present and not an undefined-like initializer const defaultIsUndefined = typeof defaultPart === 'string' && /(^|\W)undefined(\W|$)/.test(defaultPart); if (bang || (defaultPart !== undefined && !defaultIsUndefined)) { required.push(name); } pendingDesc = undefined; continue; } } const schema = { type: 'object', additionalProperties: false, properties }; if (required.length) schema.required = required; if (description) schema.description = description; return schema; } catch (_) { return { type: 'object', additionalProperties: true }; } } function tryResolveFromNearbyFiles(filePath, className, seen) { try { const dir = path.dirname(filePath); const candidates = []; // Ascend directories up to project api root and collect models/types.ts let cur = dir; while (cur && cur.length > 1) { candidates.push(path.join(cur, 'models.ts')); candidates.push(path.join(cur, 'types.ts')); const parent = path.dirname(cur); if (parent === cur) break; cur = parent; // stop if we reached api root if (path.basename(cur) === 'api') break; } // Try api root models/types as well const parts = dir.split(path.sep); const apiIdx = parts.lastIndexOf('api'); if (apiIdx !== -1) { const apiRoot = parts.slice(0, apiIdx + 1).join(path.sep); candidates.push(path.join(apiRoot, 'models.ts')); candidates.push(path.join(apiRoot, 'types.ts')); } for (const cand of candidates) { if (fs.existsSync(cand)) { const schema = schemaFromTsDeclaration(cand, className, seen); if (schema && schema.properties) return schema; } } } catch (_) { /* ignore */ } return null; } function tsTypeToJsonSchema(tsType, filePath, seen) { // Basic mappings const arrayMatch = tsType.match(/^Array<(.+)>$/) || tsType.match(/^(.+)\[\]$/); if (arrayMatch) { return { type: 'array', items: tsTypeToJsonSchema(arrayMatch[1].trim(), filePath, seen) }; } const base = tsType.replace(/\s*\|\s*undefined/g, '').trim(); if (base === 'string') return { type: 'string' }; if (base === 'number') return { type: 'number' }; if (base === 'integer' || base === 'int' || base === 'int64') return { type: 'integer', format: 'int64' }; if (base === 'boolean') return { type: 'boolean' }; if (base === 'object' || base === 'Record<string, any>') return { type: 'object' }; // Inline object literal type: { key: type; key2: type; } if (base.startsWith('{') && base.endsWith('}')) { const inner = base.slice(1, -1).trim(); const properties = {}; const parts = inner.split(';').map(s => s.trim()).filter(Boolean); for (const part of parts) { const m = part.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/); if (m) { const key = m[1]; const t = m[2].trim(); properties[key] = tsTypeToJsonSchema(t, filePath, seen); } } return { type: 'object', additionalProperties: false, properties }; } // Attempt to resolve class types declared in the same file if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) { try { const nested = schemaFromTsDeclaration(filePath, base, seen); if (nested && nested.properties) return nested; const nearby = tryResolveFromNearbyFiles(filePath, base, seen); if (nearby) return nearby; return {}; } catch (_) { // ignore } } // Fallback return {}; } // Parse basic TS default literals into JS values for examples function parseTsDefaultLiteral(lit) { const s = (lit || '').trim(); if (!s) return undefined; const str = s.match(/^(["'])([\s\S]*)\1$/); if (str) return str[2].replace(/\\"/g, '"').replace(/\\'/g, "'"); if (s === 'true') return true; if (s === 'false') return false; if (/^[-+]?[0-9]+(\.[0-9]+)?$/.test(s)) return Number(s); if (s.startsWith('[') && s.endsWith(']')) { try { return JSON.parse(s.replace(/'/g, '"')); } catch (_) {} } return undefined; } module.exports.schemaFromTsDeclaration = schemaFromTsDeclaration; module.exports.tsSchema = schemaFromTsDeclaration; /** * Convenience helper to build apiSpec from TS Request/Response classes * Defaults to classes named 'Request' and 'Response' in the same file */ function apiSpecTs(filePath, options = {}) { const { bodyClass = 'Request', responseClass = 'Response', ...extras } = options; const specOptions = { ...extras }; try { const src = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; const hasReq = bodyClass && new RegExp('class\\s+' + bodyClass + '\\b').test(src); const hasRes = responseClass && new RegExp('class\\s+' + responseClass + '\\b').test(src); if (hasReq) { specOptions.body = schemaFromTsDeclaration(filePath, bodyClass); } if (hasRes) { specOptions.response = schemaFromTsDeclaration(filePath, responseClass); } } catch (_) { // Fallback: only set response specOptions.response = schemaFromTsDeclaration(filePath, responseClass); } return apiSpec(specOptions); } module.exports.apiSpecTs = apiSpecTs;