|
| 1 | +import type { ApiPipeline } from '@genapi/shared' |
| 2 | +import type { SchemaRoute } from '../parser' |
| 3 | +import { compilerTsRequestDeclaration, compilerTsTypingsDeclaration } from '@genapi/pipeline' |
| 4 | + |
| 5 | +const Endpoint = Symbol.for('Endpoint') |
| 6 | +const DynamicParam = Symbol.for('DynamicParam') |
| 7 | + |
| 8 | +interface SchemaNode { |
| 9 | + [key: string]: SchemaNode | { |
| 10 | + [Endpoint]?: Record<string, { |
| 11 | + query?: string |
| 12 | + body?: string |
| 13 | + headers?: string |
| 14 | + response: string |
| 15 | + }> |
| 16 | + [DynamicParam]?: SchemaNode |
| 17 | + } |
| 18 | +} |
| 19 | + |
| 20 | +// Basic types that should not be prefixed with Types. |
| 21 | +const BASIC_TYPES = new Set(['string', 'number', 'boolean', 'void', 'any', 'unknown', 'null', 'undefined']) |
| 22 | + |
| 23 | +/** |
| 24 | + * Convert type references to Types.* format |
| 25 | + * Examples: |
| 26 | + * - Pet -> Types.Pet |
| 27 | + * - Pet[] -> Types.Pet[] |
| 28 | + * - Record<string, Pet> -> Record<string, Types.Pet> |
| 29 | + */ |
| 30 | +function convertTypeToTypesNamespace(typeStr: string, interfaceNames: Set<string>): string { |
| 31 | + if (!typeStr || typeStr.trim() === '') |
| 32 | + return typeStr |
| 33 | + |
| 34 | + // Don't convert basic types |
| 35 | + if (BASIC_TYPES.has(typeStr.trim())) |
| 36 | + return typeStr |
| 37 | + |
| 38 | + // Handle array types: Pet[] -> Types.Pet[] |
| 39 | + const arrayMatch = typeStr.match(/^(.+?)\[\]$/) |
| 40 | + if (arrayMatch) { |
| 41 | + const baseType = arrayMatch[1].trim() |
| 42 | + if (interfaceNames.has(baseType)) |
| 43 | + return `Types.${baseType}[]` |
| 44 | + // Recursively convert nested types in arrays |
| 45 | + return `${convertTypeToTypesNamespace(baseType, interfaceNames)}[]` |
| 46 | + } |
| 47 | + |
| 48 | + // Handle Record types: Record<string, Pet> -> Record<string, Types.Pet> |
| 49 | + const recordMatch = typeStr.match(/^Record<(.+)>$/) |
| 50 | + if (recordMatch) { |
| 51 | + const innerTypes = recordMatch[1] |
| 52 | + const convertedInner = innerTypes.split(',').map((t) => { |
| 53 | + const trimmed = t.trim() |
| 54 | + if (interfaceNames.has(trimmed)) |
| 55 | + return `Types.${trimmed}` |
| 56 | + return convertTypeToTypesNamespace(trimmed, interfaceNames) |
| 57 | + }).join(', ') |
| 58 | + return `Record<${convertedInner}>` |
| 59 | + } |
| 60 | + |
| 61 | + // Handle union types: Pet | Order -> Types.Pet | Types.Order |
| 62 | + if (typeStr.includes('|')) { |
| 63 | + return typeStr.split('|').map((t) => { |
| 64 | + const trimmed = t.trim() |
| 65 | + if (interfaceNames.has(trimmed)) |
| 66 | + return `Types.${trimmed}` |
| 67 | + return convertTypeToTypesNamespace(trimmed, interfaceNames) |
| 68 | + }).join(' | ') |
| 69 | + } |
| 70 | + |
| 71 | + // Handle intersection types: Pet & Order -> Types.Pet & Types.Order |
| 72 | + if (typeStr.includes('&')) { |
| 73 | + return typeStr.split('&').map((t) => { |
| 74 | + const trimmed = t.trim() |
| 75 | + if (interfaceNames.has(trimmed)) |
| 76 | + return `Types.${trimmed}` |
| 77 | + return convertTypeToTypesNamespace(trimmed, interfaceNames) |
| 78 | + }).join(' & ') |
| 79 | + } |
| 80 | + |
| 81 | + // Handle generic types: Promise<Pet> -> Promise<Types.Pet> |
| 82 | + const genericMatch = typeStr.match(/^(\w+)<(.+)>$/) |
| 83 | + if (genericMatch) { |
| 84 | + const genericName = genericMatch[1] |
| 85 | + const innerTypes = genericMatch[2] |
| 86 | + const convertedInner = convertTypeToTypesNamespace(innerTypes, interfaceNames) |
| 87 | + return `${genericName}<${convertedInner}>` |
| 88 | + } |
| 89 | + |
| 90 | + // Handle inline object types: { name: string } - don't convert |
| 91 | + if (typeStr.trim().startsWith('{') && typeStr.trim().endsWith('}')) |
| 92 | + return typeStr |
| 93 | + |
| 94 | + // Simple type name - check if it's an interface |
| 95 | + const trimmed = typeStr.trim() |
| 96 | + if (interfaceNames.has(trimmed)) |
| 97 | + return `Types.${trimmed}` |
| 98 | + |
| 99 | + return typeStr |
| 100 | +} |
| 101 | + |
| 102 | +function buildSchemaTree(routes: SchemaRoute[], interfaceNames: Set<string>): SchemaNode { |
| 103 | + const root: SchemaNode = {} |
| 104 | + |
| 105 | + for (const route of routes) { |
| 106 | + // Normalize path: remove leading slash and split |
| 107 | + const normalizedPath = route.path.startsWith('/') ? route.path.slice(1) : route.path |
| 108 | + const pathParts = normalizedPath ? normalizedPath.split('/') : [] |
| 109 | + let current = root |
| 110 | + |
| 111 | + // Handle root path '/' |
| 112 | + if (pathParts.length === 0) { |
| 113 | + if (!current[Endpoint as any]) { |
| 114 | + current[Endpoint as any] = {} |
| 115 | + } |
| 116 | + const endpoint = current[Endpoint as any] as Record<string, any> |
| 117 | + const methodDef: Record<string, any> = { |
| 118 | + response: convertTypeToTypesNamespace(route.responseType, interfaceNames), |
| 119 | + } |
| 120 | + if (route.queryType) |
| 121 | + methodDef.query = convertTypeToTypesNamespace(route.queryType, interfaceNames) |
| 122 | + if (route.bodyType) |
| 123 | + methodDef.body = convertTypeToTypesNamespace(route.bodyType, interfaceNames) |
| 124 | + if (route.headersType) |
| 125 | + methodDef.headers = convertTypeToTypesNamespace(route.headersType, interfaceNames) |
| 126 | + endpoint[route.method] = methodDef |
| 127 | + continue |
| 128 | + } |
| 129 | + |
| 130 | + for (let i = 0; i < pathParts.length; i++) { |
| 131 | + const part = pathParts[i] |
| 132 | + const isParam = part.startsWith('{') && part.endsWith('}') |
| 133 | + |
| 134 | + if (isParam) { |
| 135 | + // Dynamic parameter |
| 136 | + if (!current[DynamicParam as any]) { |
| 137 | + current[DynamicParam as any] = {} |
| 138 | + } |
| 139 | + current = current[DynamicParam as any] as SchemaNode |
| 140 | + } |
| 141 | + else { |
| 142 | + // Static path segment |
| 143 | + if (!current[part]) { |
| 144 | + current[part] = {} |
| 145 | + } |
| 146 | + current = current[part] as SchemaNode |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + // Add endpoint |
| 151 | + if (!current[Endpoint as any]) { |
| 152 | + current[Endpoint as any] = {} |
| 153 | + } |
| 154 | + const endpoint = current[Endpoint as any] as Record<string, any> |
| 155 | + const methodDef: Record<string, any> = { |
| 156 | + response: convertTypeToTypesNamespace(route.responseType, interfaceNames), |
| 157 | + } |
| 158 | + if (route.queryType) |
| 159 | + methodDef.query = convertTypeToTypesNamespace(route.queryType, interfaceNames) |
| 160 | + if (route.bodyType) |
| 161 | + methodDef.body = convertTypeToTypesNamespace(route.bodyType, interfaceNames) |
| 162 | + if (route.headersType) |
| 163 | + methodDef.headers = convertTypeToTypesNamespace(route.headersType, interfaceNames) |
| 164 | + endpoint[route.method] = methodDef |
| 165 | + } |
| 166 | + |
| 167 | + return root |
| 168 | +} |
| 169 | + |
| 170 | +function schemaNodeToTypeScript(node: SchemaNode, indent = 0, isRoot = false): string { |
| 171 | + const spaces = ' '.repeat(indent) |
| 172 | + const lines: string[] = [] |
| 173 | + |
| 174 | + // Handle endpoint (for root path or nested paths) |
| 175 | + if (node[Endpoint as any]) { |
| 176 | + lines.push(`${spaces}[Endpoint]: {`) |
| 177 | + const endpoint = node[Endpoint as any] as Record<string, any> |
| 178 | + for (const [method, def] of Object.entries(endpoint)) { |
| 179 | + const methodLines: string[] = [] |
| 180 | + methodLines.push(`${spaces} ${method}: {`) |
| 181 | + if (def.query) |
| 182 | + methodLines.push(`${spaces} query: ${def.query},`) |
| 183 | + if (def.body) |
| 184 | + methodLines.push(`${spaces} body: ${def.body},`) |
| 185 | + if (def.headers) |
| 186 | + methodLines.push(`${spaces} headers: ${def.headers},`) |
| 187 | + methodLines.push(`${spaces} response: ${def.response},`) |
| 188 | + methodLines.push(`${spaces} },`) |
| 189 | + lines.push(...methodLines) |
| 190 | + } |
| 191 | + lines.push(`${spaces}},`) |
| 192 | + } |
| 193 | + |
| 194 | + // Handle dynamic param |
| 195 | + if (node[DynamicParam as any]) { |
| 196 | + lines.push(`${spaces}[DynamicParam]: {`) |
| 197 | + lines.push(schemaNodeToTypeScript(node[DynamicParam as any] as SchemaNode, indent + 1)) |
| 198 | + lines.push(`${spaces}},`) |
| 199 | + } |
| 200 | + |
| 201 | + // Handle static paths |
| 202 | + for (const [key, value] of Object.entries(node)) { |
| 203 | + if (key === Endpoint.toString() || key === DynamicParam.toString()) |
| 204 | + continue |
| 205 | + |
| 206 | + // For root level, add leading slash |
| 207 | + const pathKey = isRoot ? `'/${key}'` : `'${key}'` |
| 208 | + lines.push(`${spaces}${pathKey}: {`) |
| 209 | + lines.push(schemaNodeToTypeScript(value as SchemaNode, indent + 1)) |
| 210 | + lines.push(`${spaces}},`) |
| 211 | + } |
| 212 | + |
| 213 | + return lines.join('\n') |
| 214 | +} |
| 215 | + |
| 216 | +export function compiler(configRead: ApiPipeline.ConfigRead): ApiPipeline.ConfigRead { |
| 217 | + const routes = configRead.__schemaRoutes as SchemaRoute[] || [] |
| 218 | + |
| 219 | + // Get all interface names for type conversion |
| 220 | + const interfaceNames = new Set<string>( |
| 221 | + (configRead.graphs.interfaces || []).map((i: { name: string }) => i.name), |
| 222 | + ) |
| 223 | + // Also include typings that are type aliases (not inline types) |
| 224 | + const typings = configRead.graphs.typings || [] |
| 225 | + for (const typing of typings) { |
| 226 | + if (typing.name && typeof typing.value === 'string' && !typing.value.includes('{')) { |
| 227 | + // Simple type alias, not inline type |
| 228 | + interfaceNames.add(typing.name) |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + // Build schema tree |
| 233 | + const schemaTree = routes.length > 0 ? buildSchemaTree(routes, interfaceNames) : {} |
| 234 | + |
| 235 | + // Generate schema interface code |
| 236 | + const schemaCode = schemaNodeToTypeScript(schemaTree, 0, true) |
| 237 | + const schemaInterfaceContent = schemaCode.trim() |
| 238 | + |
| 239 | + // Generate $fetch function |
| 240 | + configRead.graphs.functions = [{ |
| 241 | + export: true, |
| 242 | + name: '$fetch', |
| 243 | + generics: [{ name: 'T', extends: 'TypedFetchInput<APISchema>' }], |
| 244 | + parameters: [ |
| 245 | + { |
| 246 | + name: 'input', |
| 247 | + type: 'T', |
| 248 | + required: true, |
| 249 | + }, |
| 250 | + { |
| 251 | + name: 'init', |
| 252 | + type: 'TypedFetchRequestInit<APISchema, T>', |
| 253 | + required: false, |
| 254 | + }, |
| 255 | + ], |
| 256 | + returnType: 'Promise<TypedResponse<TypedFetchResponseBody<APISchema, T>>>', |
| 257 | + async: true, |
| 258 | + body: [ |
| 259 | + 'return ofetch(input, init as RequestInit) as unknown as Promise<TypedResponse<TypedFetchResponseBody<APISchema, T>>>', |
| 260 | + ], |
| 261 | + }] |
| 262 | + |
| 263 | + // Generate code for each output |
| 264 | + for (const output of configRead.outputs) { |
| 265 | + if (output.type === 'request' && !configRead.config.onlyDeclaration) { |
| 266 | + // Generate code using standard compiler (imports are already configured in config) |
| 267 | + const requestCode = compilerTsRequestDeclaration(configRead) |
| 268 | + |
| 269 | + // Find where imports end and insert schema interface |
| 270 | + const lines = requestCode.split('\n') |
| 271 | + let lastImportIndex = -1 |
| 272 | + for (let i = 0; i < lines.length; i++) { |
| 273 | + if (lines[i].trim().startsWith('import')) |
| 274 | + lastImportIndex = i |
| 275 | + } |
| 276 | + |
| 277 | + // Insert schema interface after imports |
| 278 | + const schemaInterface = `// Define your API schema\ninterface APISchema {\n${schemaInterfaceContent}\n}\n\n` |
| 279 | + if (lastImportIndex >= 0) { |
| 280 | + const beforeSchema = lines.slice(0, lastImportIndex + 1).join('\n') |
| 281 | + const afterSchema = lines.slice(lastImportIndex + 1).join('\n') |
| 282 | + output.code = `${beforeSchema}\n${schemaInterface}${afterSchema}` |
| 283 | + } |
| 284 | + else { |
| 285 | + output.code = `${schemaInterface}${requestCode}` |
| 286 | + } |
| 287 | + } |
| 288 | + if (output.type === 'typings') { |
| 289 | + output.code = compilerTsTypingsDeclaration(configRead) |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + return configRead |
| 294 | +} |
0 commit comments