Skip to content

Commit 48b845b

Browse files
committed
feat: add support for new ofetch schema handling, including configuration and parser enhancements, and integrate fetchdts for improved type imports
1 parent 1adeb06 commit 48b845b

16 files changed

Lines changed: 977 additions & 26 deletions

File tree

packages/pipeline/src/compiler/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ export function compiler(configRead: ApiPipeline.ConfigRead) {
1919

2020
return configRead
2121
}
22+
23+
export { compilerTsRequestDeclaration, compilerTsTypingsDeclaration }

packages/presets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"scripts": {
3939
"build": "tsdown",
4040
"dev": "tsdown --watch",
41+
"test": "vitest run",
4142
"typecheck": "tsc --noEmit",
4243
"automd": "automd",
4344
"prepublishOnly": "nr build"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as js } from './js'
2+
export { default as schema } from './schema'
23
export { default as ts } from './ts'
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ApiPipeline } from '@genapi/shared'
2+
import { config as _config } from '@genapi/pipeline'
3+
4+
export function config(userConfig: ApiPipeline.Config): ApiPipeline.ConfigRead {
5+
userConfig.import = userConfig.import || {}
6+
userConfig.import.http = userConfig.import.http || 'ofetch'
7+
8+
const configRead = _config(userConfig)
9+
10+
configRead.graphs.imports.push({
11+
value: userConfig.import.http,
12+
names: ['ofetch'],
13+
})
14+
15+
configRead.graphs.imports.push({
16+
names: [
17+
'TypedFetchInput',
18+
'TypedFetchRequestInit',
19+
'TypedFetchResponseBody',
20+
'TypedResponse',
21+
'Endpoint',
22+
'DynamicParam',
23+
],
24+
value: 'fetchdts',
25+
})
26+
27+
return configRead
28+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { ApiPipeline } from '@genapi/shared'
2+
import pipeline, { dest, generate, original } from '@genapi/pipeline'
3+
4+
import { compiler } from './compiler'
5+
import { config } from './config'
6+
import { parser } from './parser'
7+
8+
function openapiPipeline(userConfig: ApiPipeline.Config) {
9+
const process = pipeline(
10+
userConfig => config(userConfig),
11+
configRead => original(configRead),
12+
configRead => parser(configRead),
13+
configRead => compiler(configRead),
14+
configRead => generate(configRead),
15+
configRead => dest(configRead),
16+
)
17+
return process(userConfig)
18+
}
19+
export { compiler, config, dest, generate, original, parser }
20+
21+
openapiPipeline.config = config
22+
openapiPipeline.original = original
23+
openapiPipeline.parser = parser
24+
openapiPipeline.compiler = compiler
25+
openapiPipeline.generate = generate
26+
openapiPipeline.dest = dest
27+
export default openapiPipeline

0 commit comments

Comments
 (0)