@simplyhomes/sos-sdk
Version:
TypeScript SDK for Simply Homes SoS API v4
374 lines (370 loc) • 16.6 kB
JavaScript
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;
}