UNPKG

@simplyhomes/sos-sdk

Version:

TypeScript SDK for Simply Homes SoS API v4

374 lines (370 loc) 16.6 kB
import { detectDtoWrapperProperty } from './type-detector'; import { toPascalCase, toCamelCase, normalizeEndpoint } from './utils'; /** * Generate a complete resource wrapper file */ export function generateResourceFile(resourceName, config, typeMap, bodyDtoSet) { const className = toPascalCase(resourceName); const imports = new Set(); const typesToImport = new Set(); // Collect individual types to import const subresourceClasses = []; const subresourceProps = []; const subresourceInits = []; const directMethods = []; imports.add(`import type { Configuration } from '../generated';`); // Detect which API class to import const apiClass = detectApiClass(config, typeMap); imports.add(`import { ${apiClass} } from '../generated';`); // Generate direct methods (for resources like "version" that have methods directly) if (config.methods) { for (const [methodName, methodConfig] of Object.entries(config.methods)) { const methodCode = generateMethod(methodName, methodConfig, typeMap, typesToImport, apiClass, bodyDtoSet); if (methodCode) { directMethods.push(methodCode); } } } // Generate subresource classes if (config.subresources) { for (const [subName, subConfig] of Object.entries(config.subresources)) { const subClassName = `${className}${toPascalCase(subName)}`; const subCode = generateSubresourceClass(subClassName, subConfig, typeMap, typesToImport, apiClass, bodyDtoSet); subresourceClasses.push(subCode); const propName = toCamelCase(subName); subresourceProps.push(`\tpublic readonly ${propName}: ${subClassName};`); subresourceInits.push(`\t\tthis.${propName} = new ${subClassName}(api);`); } } // Consolidate all types into a single import statement if (typesToImport.size > 0) { const sortedTypes = Array.from(typesToImport).sort(); imports.add(`import type {\n\t${sortedTypes.join(',\n\t')}\n} from '../generated';`); } // Generate main resource class const hasSubresources = subresourceProps.length > 0; const hasDirectMethods = directMethods.length > 0; let mainClass = ''; if (hasSubresources || hasDirectMethods) { // Always accept Configuration and create API instance internally mainClass = `export class ${className} { ${hasSubresources ? subresourceProps.join('\n') + '\n' : ''}${hasDirectMethods && !hasSubresources ? '\tprivate api: ' + apiClass + ';\n' : ''} \tconstructor(config: Configuration) { \t\tconst api = new ${apiClass}(config); ${hasDirectMethods && !hasSubresources ? '\t\tthis.api = api;\n' : ''}${hasSubresources ? subresourceInits.join('\n') + '\n' : ''}\t} ${hasDirectMethods ? '\n' + directMethods.join('\n\n') : ''} }`; } return `${Array.from(imports).join('\n')} ${mainClass}${subresourceClasses.length > 0 ? '\n\n' + subresourceClasses.join('\n\n') : ''} `; } /** * Generate a subresource class (e.g., ViewsList, ViewsCreate, etc.) */ function generateSubresourceClass(className, config, typeMap, typesToImport, apiClass, bodyDtoSet) { const methods = []; const nestedClasses = []; const nestedProps = []; const nestedInits = []; // Generate methods if (config.methods) { for (const [methodName, methodConfig] of Object.entries(config.methods)) { const methodCode = generateMethod(methodName, methodConfig, typeMap, typesToImport, apiClass, bodyDtoSet); if (methodCode) { methods.push(methodCode); } } } // Generate nested subresources (e.g., airtable.tables.records.comments) if (config.subresources) { for (const [subName, subConfig] of Object.entries(config.subresources)) { const nestedClassName = `${className}${toPascalCase(subName)}`; const nestedCode = generateSubresourceClass(nestedClassName, subConfig, typeMap, typesToImport, apiClass, bodyDtoSet); nestedClasses.push(nestedCode); const propName = toCamelCase(subName); nestedProps.push(`\tpublic readonly ${propName}: ${nestedClassName};`); nestedInits.push(`\t\tthis.${propName} = new ${nestedClassName}(api);`); } } const hasNested = nestedProps.length > 0; return `export class ${className} { ${hasNested ? nestedProps.join('\n') + '\n\n' : ''}\tconstructor(private api: ${apiClass}) { ${hasNested ? nestedInits.join('\n') : ''} \t} ${methods.length > 0 ? '\n' + methods.join('\n\n') : ''} }${nestedClasses.length > 0 ? '\n\n' + nestedClasses.join('\n\n') : ''}`; } /** * Generate a single method implementation (returns null if endpoint not found) */ function generateMethod(methodName, methodConfig, typeMap, typesToImport, apiClass, bodyDtoSet) { const endpoint = typeof methodConfig === 'string' ? methodConfig : methodConfig.endpoint; const positionalParams = typeof methodConfig === 'object' ? methodConfig.positional_params : undefined; const normalizedEndpoint = normalizeEndpoint(endpoint); const detected = typeMap.get(normalizedEndpoint); if (!detected) { // Skip methods that don't exist in generated code (validation warnings already shown) return null; } // Extract path parameters from endpoint to check if we need request type const pathParamMatches = endpoint.match(/\{(\w+)\}/g); const pathParams = pathParamMatches ? pathParamMatches.map(m => m.slice(1, -1)) : []; const positionalParamsToUse = positionalParams && positionalParams.length > 0 ? positionalParams : pathParams; // Determine HTTP method const httpMethod = endpoint.split(' ')[0].toLowerCase(); const isMutation = ['post', 'put', 'patch'].includes(httpMethod); // Check if method actually has params (not just a mutation marker) const hasRequestType = detected.requestType !== 'NoParams'; const hasParams = (positionalParamsToUse.length > 0 || isMutation) && hasRequestType; // Add types to import set (collected at resource level for consolidation) if (hasRequestType) { typesToImport.add(detected.requestType); // For mutations, also import the body DTO type if it actually exists if (isMutation) { const bodyTypeName = deriveBodyTypeName(detected.requestType); // Only import if the body DTO actually exists if (bodyDtoSet.has(bodyTypeName)) { typesToImport.add(bodyTypeName); } } } // Always add response type if not void if (detected.responseType !== 'void') { typesToImport.add(detected.responseType); } // Generate parameter list const params = []; // Use the params we already extracted const paramsToAdd = positionalParamsToUse; // Add path parameters as positional (only if we have params) if (hasParams) { for (const param of paramsToAdd) { params.push(`${param}: ${detected.requestType}['${param}']`); } } // Add body parameter for mutations (only if there's a request type) if (isMutation && hasRequestType) { // Try to derive the body DTO type name from the request type const bodyTypeName = deriveBodyTypeName(detected.requestType); // Check if the body DTO actually exists if (bodyDtoSet.has(bodyTypeName)) { // Check if it has a wrapper property (NESTED pattern) const wrapperProperty = detectDtoWrapperProperty(bodyTypeName); if (wrapperProperty) { // NESTED pattern: parameter type is the inner type (extract it from DTO) params.push(`body: ${bodyTypeName}['${wrapperProperty}']`); } else { // DIRECT pattern: parameter type is the DTO itself params.push(`body: ${bodyTypeName}`); } } else { // Fall back to the original approach for edge cases (multipart/form-data, body-less mutations, etc.) if (paramsToAdd.length > 0) { params.push(`body: Omit<${detected.requestType}, ${paramsToAdd.map(p => `'${p}'`).join(' | ')}>`); } else { params.push(`body: ${detected.requestType}`); } } } // Add optional options parameter for query params (non-mutations only) // This allows passing optional parameters like viewId, limit, offset, etc. if (!isMutation && hasRequestType && paramsToAdd.length > 0) { params.push(`options?: Omit<${detected.requestType}, ${paramsToAdd.map(p => `'${p}'`).join(' | ')}>`); } else if (!isMutation && hasRequestType && paramsToAdd.length === 0) { // For methods with no path params, the full request type is the options params.push(`options?: ${detected.requestType}`); } // Generate call parameters - build request object let callParamsStr; if (!hasRequestType) { // No request type at all (methods with no parameters) callParamsStr = ''; } else if (paramsToAdd.length === 0 && !isMutation) { // No params at all (like GET /v4/entities/schema or GET /v4/transactions with all optional) callParamsStr = '...options'; } else if (paramsToAdd.length === 0 && isMutation) { // Just body, no path params const bodyTypeName = deriveBodyTypeName(detected.requestType); if (bodyDtoSet.has(bodyTypeName)) { // Check if the body DTO has a nested wrapper property const wrapperProperty = detectDtoWrapperProperty(bodyTypeName); const bodyParamName = deriveBodyParamName(detected.requestType); if (wrapperProperty) { // NESTED pattern: wrap body in the detected property callParamsStr = `${bodyParamName}: { ${wrapperProperty}: body }`; } else { // DIRECT pattern: pass body directly wrapped in DTO property callParamsStr = `${bodyParamName}: body`; } } else { // Non-standard pattern (multipart/form-data, body-less mutation, etc.) - spread it callParamsStr = '...body'; } } else if (isMutation) { // Path params + body const bodyTypeName = deriveBodyTypeName(detected.requestType); if (bodyDtoSet.has(bodyTypeName)) { // Check if the body DTO has a nested wrapper property const wrapperProperty = detectDtoWrapperProperty(bodyTypeName); const bodyParamName = deriveBodyParamName(detected.requestType); if (wrapperProperty) { // NESTED pattern: wrap body in the detected property callParamsStr = paramsToAdd.join(', ') + `, ${bodyParamName}: { ${wrapperProperty}: body }`; } else { // DIRECT pattern: pass body directly wrapped in DTO property callParamsStr = paramsToAdd.join(', ') + `, ${bodyParamName}: body`; } } else { // Non-standard pattern (multipart/form-data, body-less mutation, etc.) - spread it callParamsStr = paramsToAdd.join(', ') + ', ...body'; } } else { // Path params + optional query params (GET/DELETE) callParamsStr = paramsToAdd.join(', ') + ', ...options'; } // Generate JSDoc comment const comment = `\t/**\n\t * ${methodName} - ${endpoint}\n\t */`; // Generate return type annotation const returnType = detected.responseType === 'void' ? 'Promise<void>' : `Promise<${detected.responseType}>`; return `${comment} \t${methodName}(${params.join(', ')}): ${returnType} { \t\treturn this.api.${detected.methodName}({ ${callParamsStr} }); \t}`; } /** * Generate the main V4 aggregator class */ export function generateV4File(resources) { const imports = ['import type { Configuration } from \'../generated\';']; const props = []; const inits = []; // Sort resources for consistent output const sortedResources = Object.keys(resources).sort(); for (const resourceName of sortedResources) { const className = toPascalCase(resourceName); imports.push(`import { ${className} } from './${resourceName}';`); props.push(`\tpublic readonly ${toCamelCase(resourceName)}: ${className};`); inits.push(`\t\tthis.${toCamelCase(resourceName)} = new ${className}(config);`); } return `${imports.join('\n')} /** * V4 API resources * Auto-generated by SDK generator - do not edit manually */ export class V4 { ${props.join('\n')} \tconstructor(private config: Configuration) { ${inits.join('\n')} \t} } `; } /** * Detect which API class a resource uses */ function detectApiClass(config, typeMap) { // Extract all endpoints from resource and find first one that exists const allEndpoints = getAllEndpoints(config); if (allEndpoints.length === 0) { throw new Error('Resource has no methods or subresources'); } for (const endpoint of allEndpoints) { const detected = typeMap.get(normalizeEndpoint(endpoint)); if (detected) { return detected.apiClass; } } // If none found, throw error with all attempted endpoints throw new Error(`Could not detect API class for any endpoint. Tried:\n` + allEndpoints.map(e => ` - ${e}`).join('\n')); } /** * Get all endpoints from resource config (recursively) */ function getAllEndpoints(config) { const endpoints = []; // Check methods first if (config.methods) { for (const method of Object.values(config.methods)) { const endpoint = typeof method === 'string' ? method : method.endpoint; endpoints.push(endpoint); } } // Check subresources if (config.subresources) { for (const subresource of Object.values(config.subresources)) { endpoints.push(...getAllEndpoints(subresource)); } } return endpoints; } /** * Find body parameter name in request type based on naming conventions */ function findBodyParam(requestType, httpMethod) { // Common patterns for body parameters in OpenAPI generated code const patterns = [ /Dto$/i, // e.g., v4ViewsCreateViewBodyDto /Body$/i, // e.g., createViewBody /Request$/i, // e.g., createViewRequest ]; // Extract camelCase param name from request type // V4ViewsCreateViewBodyDto -> likely has param like "v4ViewsCreateViewBodyDto" const camelCaseType = requestType.charAt(0).toLowerCase() + requestType.slice(1); // Check if it matches common body parameter patterns for (const pattern of patterns) { if (pattern.test(requestType)) { return camelCaseType; } } // For POST/PUT/PATCH, if we can't find a clear body param, use common names if (['post', 'put', 'patch'].includes(httpMethod)) { return camelCaseType; // Default to camelCase version of type name } return null; } /** * Derive the body DTO type name from the request type * * Converts request type names to their corresponding body DTO type names: * - V4TransactionsControllerUpdateTransactionV4Request → V4TransactionsUpdateTransactionBodyDto * - V4InspectionsControllerCreateInspectionV4Request → V4InspectionsCreateInspectionBodyDto */ function deriveBodyTypeName(requestType) { // Pattern: V4{Resource}Controller{Action}{Resource}V4Request // Target: V4{Resource}{Action}{Resource}BodyDto // Remove "Controller" from the type name let bodyTypeName = requestType.replace('Controller', ''); // Remove "V4" before "Request" and replace "Request" with "BodyDto" bodyTypeName = bodyTypeName.replace(/V4Request$/, 'BodyDto'); return bodyTypeName; } /** * Derive the body parameter property name from the request type * * Converts request type names to their corresponding body DTO parameter names: * - V4TransactionsControllerUpdateTransactionV4Request → v4TransactionsUpdateTransactionBodyDto * - V4InspectionsControllerCreateInspectionV4Request → v4InspectionsCreateInspectionBodyDto */ function deriveBodyParamName(requestType) { // Get the body type name and convert to camelCase for the property name const bodyTypeName = deriveBodyTypeName(requestType); const bodyParamName = bodyTypeName.charAt(0).toLowerCase() + bodyTypeName.slice(1); return bodyParamName; }