UNPKG

@prism-engineer/router

Version:

Type-safe Express.js router with automatic client generation

342 lines 15.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createTypeScriptClientGenerator = void 0; exports.extractTypeFromSchema = extractTypeFromSchema; const json_schema_to_typescript_1 = require("json-schema-to-typescript"); const createTypeScriptClientGenerator = () => ({ async generateClient(options, routes) { if (routes.length === 0) { throw new Error('No routes found'); } return await generateTypeSafeClient(options, routes); } }); exports.createTypeScriptClientGenerator = createTypeScriptClientGenerator; // Type extraction functions async function extractTypeFromSchema(schema, typeName = 'S') { const source = await (0, json_schema_to_typescript_1.compile)(schema, typeName, { style: { unionType: 'type', }, additionalProperties: false, }); return source; } // Generate consistent route key function generateRouteKey(route) { // Convert path to camelCase-like format: /agents/all -> AgentsAll, /agents/{id} -> AgentsId const pathParts = route.path.replace(/^\//, '').split('/').filter((part) => part.length > 0); const cleanPath = pathParts.map((part) => { // Handle path parameters like {id} -> Id if (part.startsWith('{') && part.endsWith('}')) { const paramName = part.slice(1, -1); return paramName.charAt(0).toUpperCase() + paramName.slice(1); } // Convert kebab-case to PascalCase return part.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''); }).join(''); return `${route.method.toUpperCase()}_${cleanPath}`; } // Generate all types first with proper names async function generateAllTypes(routes) { const types = []; const typeMap = new Map(); const schemaMap = new Map(); // Store original JSON schemas const seenTypes = new Set(); // Track seen types to avoid duplicates for (const route of routes) { const routeKey = generateRouteKey(route); // Generate request types and store schemas if (route.request?.query) { const typeName = `${routeKey}_Query`; if (!seenTypes.has(typeName)) { const typeDefinition = await extractTypeFromSchema(route.request.query, typeName); types.push(typeDefinition); seenTypes.add(typeName); } typeMap.set(`${routeKey}_query`, typeName); schemaMap.set(`${routeKey}_query_schema`, route.request.query); } if (route.request?.body) { const typeName = `${routeKey}_Body`; if (!seenTypes.has(typeName)) { const typeDefinition = await extractTypeFromSchema(route.request.body, typeName); types.push(typeDefinition); seenTypes.add(typeName); } typeMap.set(`${routeKey}_body`, typeName); schemaMap.set(`${routeKey}_body_schema`, route.request.body); } if (route.request?.headers) { const typeName = `${routeKey}_Headers`; if (!seenTypes.has(typeName)) { const typeDefinition = await extractTypeFromSchema(route.request.headers, typeName); types.push(typeDefinition); seenTypes.add(typeName); } typeMap.set(`${routeKey}_headers`, typeName); schemaMap.set(`${routeKey}_headers_schema`, route.request.headers); } // Generate response types and store schemas if (route.response) { for (const [statusCode, responseSchema] of Object.entries(route.response)) { if (responseSchema.body && (responseSchema.contentType ? isJsonContentType(responseSchema.contentType) : true)) { const typeName = `${routeKey}_Response${statusCode}`; if (!seenTypes.has(typeName)) { const typeDefinition = await extractTypeFromSchema(responseSchema.body, typeName); types.push(typeDefinition); seenTypes.add(typeName); } typeMap.set(`${routeKey}_response_${statusCode}`, typeName); schemaMap.set(`${routeKey}_response_${statusCode}_schema`, responseSchema.body); } if (responseSchema.headers) { const typeName = `${routeKey}_ResponseHeaders${statusCode}`; if (!seenTypes.has(typeName)) { const typeDefinition = await extractTypeFromSchema(responseSchema.headers, typeName); types.push(typeDefinition); seenTypes.add(typeName); } typeMap.set(`${routeKey}_response_headers_${statusCode}`, typeName); schemaMap.set(`${routeKey}_response_headers_${statusCode}_schema`, responseSchema.headers); } } } } return { types, typeMap, schemaMap }; } function getRequestTypes(route, typeMap) { const routeKey = generateRouteKey(route); return { query: typeMap.get(`${routeKey}_query`), body: typeMap.get(`${routeKey}_body`), headers: typeMap.get(`${routeKey}_headers`) }; } // JSON-like content types that support TypeBox schemas const JSON_CONTENT_TYPES = [ 'application/json', 'application/vnd.api+json', 'application/ld+json', 'text/json' ]; function isJsonContentType(contentType) { return JSON_CONTENT_TYPES.includes(contentType); } function getResponseUnion(route, typeMap) { if (!route.response || Object.keys(route.response).length === 0) { return '{ status: number; body: any; headers: Record<string, string> }'; } const responseTypes = []; const routeKey = generateRouteKey(route); for (const [statusCode] of Object.entries(route.response)) { let bodyType = 'any'; let headersType = 'Record<string, string>'; // Check if we have a generated body type const bodyTypeName = typeMap.get(`${routeKey}_response_${statusCode}`); if (bodyTypeName) { bodyType = bodyTypeName; } // Check if we have a generated headers type const headersTypeName = typeMap.get(`${routeKey}_response_headers_${statusCode}`); if (headersTypeName) { headersType = headersTypeName; } // Create discriminated union member responseTypes.push(`{ status: ${statusCode}; body: ${bodyType}; headers: ${headersType} }`); } return responseTypes.join(' | '); } function extractPathParams(path) { const matches = path.match(/{([^}]+)}/g); return matches ? matches.map(match => match.slice(1, -1)) : []; } function generateMethodImplementation(route, typeMap, schemaMap) { const pathParams = extractPathParams(route.path); const requestTypes = getRequestTypes(route, typeMap); const responseUnion = getResponseUnion(route, typeMap); const method = route.method.toLowerCase(); const routeKey = generateRouteKey(route); // Build parameter list const params = []; // Add path parameters first (all as strings) pathParams.forEach(param => { params.push(`${param}: string`); }); // Build options object type const optionsParts = []; if (requestTypes.query) { optionsParts.push(`query?: ${requestTypes.query}`); } if (requestTypes.body) { optionsParts.push(`body: ${requestTypes.body}`); } if (requestTypes.headers) { optionsParts.push(`headers?: ${requestTypes.headers}`); } // Add options parameter if needed if (optionsParts.length > 0) { const isOptional = !requestTypes.body; // Required if body is present const optionalMarker = isOptional ? '?' : ''; params.push(`options${optionalMarker}: { ${optionsParts.join('; ')} }`); } else if (pathParams.length === 0) { // No path params and no options - add optional empty options params.push('options?: {}'); } // Generate method implementation using makeApiCall let implementation = `${method}: async ( ${params.join(',\n ')} ): Promise< ${responseUnion} > => { return makeApiCall({ baseUrl, path: '${route.path}', method: '${route.method.toUpperCase()}',`; // Add path parameters if (pathParams.length > 0) { implementation += `\n pathParams: { ${pathParams.map(param => `${param}`).join(', ')} },`; } // Add request options if (requestTypes.query) { implementation += `\n query: options?.query,`; } if (requestTypes.body) { implementation += `\n body: options.body,`; } if (requestTypes.headers) { implementation += `\n headers: options?.headers,`; } // Build request schemas const requestSchemas = []; if (schemaMap.has(`${routeKey}_query_schema`)) { requestSchemas.push(`query: ${JSON.stringify(schemaMap.get(`${routeKey}_query_schema`))}`); } if (schemaMap.has(`${routeKey}_body_schema`)) { requestSchemas.push(`body: ${JSON.stringify(schemaMap.get(`${routeKey}_body_schema`))}`); } if (schemaMap.has(`${routeKey}_headers_schema`)) { requestSchemas.push(`headers: ${JSON.stringify(schemaMap.get(`${routeKey}_headers_schema`))}`); } implementation += `\n requestSchema: {${requestSchemas.length > 0 ? '\n ' + requestSchemas.join(',\n ') + '\n ' : ''}},`; // Build response schemas const responseSchemas = []; if (route.response) { for (const [statusCode] of Object.entries(route.response)) { const bodySchema = schemaMap.get(`${routeKey}_response_${statusCode}_schema`); const headersSchema = schemaMap.get(`${routeKey}_response_headers_${statusCode}_schema`); const statusSchemas = []; if (bodySchema) { statusSchemas.push(`body: ${JSON.stringify(bodySchema)}`); } if (headersSchema) { statusSchemas.push(`headers: ${JSON.stringify(headersSchema)}`); } if (statusSchemas.length > 0) { responseSchemas.push(`'${statusCode}': {\n ${statusSchemas.join(',\n ')}\n }`); } } } implementation += `\n responseSchema: {${responseSchemas.length > 0 ? '\n ' + responseSchemas.join(',\n ') + '\n ' : ''}}`; // Pass interceptors as the second argument to makeApiCall implementation += `\n }, interceptors) as unknown as Promise<${responseUnion}>;\n }`; return implementation; } // Type-safe client generation async function generateTypeSafeClient(options, routes) { const { name, baseUrl } = options; // Generate all types first const { types, typeMap, schemaMap } = await generateAllTypes(routes); let clientCode = ''; // Add type imports and generated types clientCode += `// Generated API client\n`; clientCode += `// This file is auto-generated. Do not edit manually.\n\n`; clientCode += `import { makeApiCall } from '@prism-engineer/router/dist/compilation/makeApiCall';\n\n`; // Add all type definitions if (types.length > 0) { clientCode += types.join('\n\n') + '\n\n'; } const interceptorsType = ` type Interceptors = ((request: any) => any)[]; `; clientCode += interceptorsType; // Start with client factory function with real implementation clientCode += `export const create${name} = (baseUrl: string = '${baseUrl || ''}', interceptors: Interceptors = []) => {\n`; clientCode += ` return {\n`; clientCode += ` api: {\n`; // Generate actual method implementations const apiStructure = buildTypeSafeApiStructure(routes, typeMap, schemaMap); clientCode += apiStructure; clientCode += ` }\n`; clientCode += ` };\n`; clientCode += `};\n`; return clientCode; } function buildTypeSafeApiStructure(routes, typeMap, schemaMap) { const structure = {}; // Group routes by path structure for (const route of routes) { const pathParts = route.path.replace(/^\//, '').split('/').filter((part) => part.length > 0); let current = structure; for (let i = 0; i < pathParts.length; i++) { const part = pathParts[i]; const cleanPart = part.startsWith('{') && part.endsWith('}') ? `_${part.slice(1, -1)}_` : part; if (i === pathParts.length - 1) { // Last segment - add method signature if (!current[cleanPart]) { current[cleanPart] = {}; } const methodImplementation = generateMethodImplementation(route, typeMap, schemaMap); current[cleanPart][route.method.toLowerCase()] = methodImplementation; } else { // Intermediate segment if (!current[cleanPart]) { current[cleanPart] = {}; } current = current[cleanPart]; } } } return generateApiStructureCode(structure, ' '); } // Helper function to determine if a property name needs quotes function needsQuotes(key) { // Check if the key is a valid JavaScript identifier // Valid identifiers must start with a letter, underscore, or dollar sign // and can contain letters, numbers, underscores, or dollar signs return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); } // Helper function to format property name with quotes if needed function formatPropertyName(key) { return needsQuotes(key) ? `'${key}'` : key; } function generateApiStructureCode(structure, indent) { let code = ''; for (const [key, value] of Object.entries(structure)) { if (typeof value === 'object' && value !== null) { const valueObj = value; const methods = Object.keys(valueObj).filter(k => typeof valueObj[k] === 'string'); const nestedStructures = Object.keys(valueObj).filter(k => typeof valueObj[k] === 'object' && valueObj[k] !== null); if (methods.length > 0 || nestedStructures.length > 0) { // This has method implementations and/or nested structures code += `${indent}${formatPropertyName(key)}: {\n`; // Add methods first for (const method of methods) { // The method code is already properly formatted code += `${indent} ${valueObj[method]},\n`; } // Add nested structures for (const nestedKey of nestedStructures) { const nestedStructure = { [nestedKey]: valueObj[nestedKey] }; code += generateApiStructureCode(nestedStructure, indent + ' '); } code += `${indent}},\n`; } } } return code; } //# sourceMappingURL=generator.js.map