@simplyhomes/sos-sdk
Version:
TypeScript SDK for Simply Homes SoS API v4
228 lines (227 loc) • 9.61 kB
JavaScript
import { readdirSync, readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { normalizeEndpoint } from './utils';
/**
* Auto-detect types and methods from generated OpenAPI code
* This reads the generated API files and extracts method names, request/response types
*/
export async function detectTypes() {
const typeMap = new Map();
// Path to generated APIs (relative to project root, not compiled location)
const apisDir = resolve(process.cwd(), 'src/generated/src/apis');
console.log(` 📁 Looking for APIs in: ${apisDir}`);
if (!existsSync(apisDir)) {
throw new Error(`Generated APIs directory not found: ${apisDir}\n` +
'Please run "pnpm gen:openapi-types" first to generate the OpenAPI types.');
}
const files = readdirSync(apisDir).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
console.log(` 📄 Found ${files.length} API files`);
for (const file of files) {
const filePath = resolve(apisDir, file);
const content = readFileSync(filePath, 'utf-8');
const apiClassName = file.replace('.ts', '');
console.log(` 🔍 Processing ${file}...`);
// Extract methods from this API class
const methods = extractMethodsFromFile(content, apiClassName);
console.log(` ✓ Found ${methods.length} methods`);
for (const method of methods) {
typeMap.set(method.endpoint, method);
}
}
console.log(` 📊 Total endpoints mapped: ${typeMap.size}`);
return typeMap;
}
/**
* Extract method information from a generated API file
*/
function extractMethodsFromFile(content, apiClassName) {
const methods = [];
// Find all method signatures in the file
// Pattern 1: async methodName(requestParameters: RequestType, ...): Promise<ResponseType>
// Also matches: async methodName(requestParameters: RequestType = {}, ...): Promise<ResponseType>
// Pattern 2: async methodName(initOverrides?: ...): Promise<ResponseType> (no requestParameters)
const methodRegexWithParams = /async\s+(\w+)\(requestParameters:\s*(\w+)(?:\s*=\s*\{[^}]*\})?,.*?\):\s*Promise<(\w+)>/g;
const methodRegexWithoutParams = /async\s+(\w+)\(initOverrides\?:.*?\):\s*Promise<(\w+)>/g;
let match;
let matchCount = 0;
// Try with requestParameters first
while ((match = methodRegexWithParams.exec(content)) !== null) {
matchCount++;
const [, methodName, requestType, responseType] = match;
// Skip if this is the "Raw" version (we want the main method)
if (methodName.endsWith('Raw')) {
continue;
}
// Extract endpoint from the method implementation
const endpoint = extractEndpointFromMethod(content, methodName);
if (endpoint) {
methods.push({
methodName,
requestType,
responseType,
apiClass: apiClassName,
endpoint,
});
}
else {
console.log(` ⚠️ Method ${methodName} has no endpoint detected`);
}
}
// Also try methods without requestParameters (like EntitiesApi)
while ((match = methodRegexWithoutParams.exec(content)) !== null) {
matchCount++;
const [, methodName, responseType] = match;
// Skip if this is the "Raw" version
if (methodName.endsWith('Raw')) {
continue;
}
// For methods without params, use special marker
const requestType = 'NoParams';
// Extract endpoint from the method implementation
const endpoint = extractEndpointFromMethod(content, methodName);
if (endpoint) {
methods.push({
methodName,
requestType, // Special marker to indicate no request type
responseType,
apiClass: apiClassName,
endpoint,
});
}
else {
console.log(` ⚠️ Method ${methodName} (no params) has no endpoint detected`);
}
}
if (matchCount === 0) {
// Debug: check if file has async methods at all
const asyncCount = (content.match(/async\s+\w+\(/g) || []).length;
console.log(` ⚠️ No matches found. File has ${asyncCount} async methods`);
// Try to find first async method to debug
const firstAsyncMatch = content.match(/async\s+(\w+)\([^)]+\):\s*Promise<[^>]+>/);
if (firstAsyncMatch) {
console.log(` 📝 Example method signature: ${firstAsyncMatch[0].substring(0, 100)}...`);
}
}
return methods;
}
/**
* Extract endpoint (HTTP method + path) from method implementation
* Looks for the `method:` and `path:` fields in the fetch call
*/
function extractEndpointFromMethod(content, methodName) {
// Find the method implementation section
const rawMethodName = `${methodName}Raw`;
const methodStartIdx = content.indexOf(`async ${rawMethodName}(`);
if (methodStartIdx === -1) {
console.log(` ⚠️ Cannot find Raw method for ${methodName}`);
return null;
}
// Find the end of the method - look for the next async method
const nextMethodIdx = content.indexOf('\n async ', methodStartIdx + 1);
const methodEndIdx = nextMethodIdx === -1 ? content.length : nextMethodIdx;
const methodSection = content.substring(methodStartIdx, methodEndIdx);
// Extract HTTP method from: method: 'GET',
const methodMatch = methodSection.match(/method:\s*['"](\w+)['"],/);
const httpMethod = methodMatch ? methodMatch[1].toLowerCase() : null;
if (!httpMethod) {
console.log(` ⚠️ Cannot find HTTP method for ${methodName}`);
return null;
}
// Extract path from: let urlPath = `/v4/views/{objectName}/schema`;
// Pattern: let urlPath = `...` or const urlPath = '...'
const pathMatch = methodSection.match(/(?:let|const)\s+urlPath\s*=\s*(`[^`]+`|['"][^'"]+['"])/);
if (!pathMatch) {
console.log(` ⚠️ Cannot find urlPath for ${methodName}`);
return null;
}
let path = pathMatch[1];
// Remove quotes or backticks
path = path.replace(/^[`'"]|[`'"]$/g, '');
// Path already has {objectName} format from generated code, no need to convert
return normalizeEndpoint(`${httpMethod} ${path}`);
}
/**
* Detect all available body DTO types from generated models
* Returns a Set of body DTO type names (e.g., "V4TransactionsUpdateTransactionBodyDto")
*/
export function detectBodyDtos() {
const bodyDtos = new Set();
const modelsDir = resolve(process.cwd(), 'src/generated/src/models');
if (!existsSync(modelsDir)) {
console.log(` ⚠️ Models directory not found: ${modelsDir}`);
return bodyDtos;
}
const files = readdirSync(modelsDir).filter((f) => f.endsWith('BodyDto.ts'));
for (const file of files) {
// Extract type name from filename (e.g., "V4TransactionsUpdateTransactionBodyDto.ts" → "V4TransactionsUpdateTransactionBodyDto")
const typeName = file.replace('.ts', '');
bodyDtos.add(typeName);
}
console.log(` 📦 Found ${bodyDtos.size} body DTO types`);
return bodyDtos;
}
/**
* Detect if a body DTO has a single wrapper property (nested DTO pattern)
*
* Example nested pattern:
* ```typescript
* export interface V4TransactionsUpdateTransactionBodyDto {
* transaction: V4TransactionsUpdateTransactionBody;
* }
* ```
* Returns: "transaction"
*
* Example direct pattern:
* ```typescript
* export interface V4LeadsUpdateLeadBodyDto {
* status?: string;
* name?: string;
* // ... many properties
* }
* ```
* Returns: null
*
* @param bodyTypeName The body DTO type name (e.g., "V4TransactionsUpdateTransactionBodyDto")
* @returns The wrapper property name if nested pattern detected, null otherwise
*/
export function detectDtoWrapperProperty(bodyTypeName) {
const modelsDir = resolve(process.cwd(), 'src/generated/src/models');
const dtsFile = resolve(modelsDir, `${bodyTypeName}.ts`);
if (!existsSync(dtsFile)) {
return null;
}
try {
const content = readFileSync(dtsFile, 'utf-8');
// Find the interface definition - use simpler regex that handles multiline
const interfaceRegex = new RegExp(`export interface ${bodyTypeName}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm');
const interfaceMatch = content.match(interfaceRegex);
if (!interfaceMatch) {
return null;
}
const interfaceBody = interfaceMatch[1];
// Split by lines and find property declarations
const lines = interfaceBody.split('\n');
const properties = [];
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comment lines
if (!trimmed || trimmed.startsWith('*') || trimmed.startsWith('//') || trimmed.startsWith('/**')) {
continue;
}
// Match property declaration: "propertyName: Type;" or "propertyName?: Type;"
const propMatch = trimmed.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)\??:\s*/);
if (propMatch) {
properties.push(propMatch[1]);
}
}
// Nested pattern: exactly ONE property
if (properties.length === 1) {
return properties[0];
}
return null;
}
catch (error) {
// If we can't read/parse the file, assume no wrapper
return null;
}
}