@codewithagents/openapi-react-query 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,823 +0,0 @@
1
- import type { OpenAPIV3_1 } from 'openapi-types'
2
-
3
- type OperationObject = OpenAPIV3_1.OperationObject
4
- type ReferenceObject = OpenAPIV3_1.ReferenceObject
5
-
6
- export interface HookGenOptions {
7
- staleTime: number
8
- gcTime: number
9
- suspense?: boolean
10
- overrides?: Record<string, { staleTime: number; gcTime: number }>
11
- autoInvalidate?: boolean
12
- }
13
-
14
- // ── Helpers ───────────────────────────────────────────────────────────────────
15
-
16
- function isRef(obj: unknown): obj is ReferenceObject {
17
- return typeof obj === 'object' && obj !== null && '$ref' in obj
18
- }
19
-
20
- function refToName(ref: string): string {
21
- const parts = ref.split('/')
22
- return parts[parts.length - 1]!
23
- }
24
-
25
- function capitalize(s: string): string {
26
- return s.charAt(0).toUpperCase() + s.slice(1)
27
- }
28
-
29
- const SUPPORTED_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
30
- type SupportedMethod = (typeof SUPPORTED_METHODS)[number]
31
-
32
- // Mirrors deriveOperationName from openapi-gen/src/plugins/client.ts
33
- function deriveOperationName(method: string, path: string): string {
34
- const prefixMap: Record<string, string> = {
35
- get: 'get',
36
- post: 'create',
37
- put: 'update',
38
- patch: 'patch',
39
- delete: 'delete',
40
- }
41
- const prefix = prefixMap[method] ?? method
42
-
43
- // Strip /api/v1/ prefix
44
- let segments = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '')
45
-
46
- const parts = segments.split('/').map((seg) => {
47
- // Handle mixed segments like "{maxLat}.{format}" — extract each {param} inside
48
- const paramMatches = seg.match(/\{([^}]+)\}/g)
49
- if (paramMatches !== null && !(seg.startsWith('{') && seg.endsWith('}'))) {
50
- return paramMatches
51
- .map((m) => {
52
- const name = sanitizeOperationId(m.slice(1, -1))
53
- return 'By' + name.charAt(0).toUpperCase() + name.slice(1)
54
- })
55
- .join('')
56
- }
57
- if (seg.startsWith('{') && seg.endsWith('}')) {
58
- const name = seg.slice(1, -1)
59
- const sanitized = sanitizeOperationId(name)
60
- return 'By' + sanitized.charAt(0).toUpperCase() + sanitized.slice(1)
61
- }
62
- const sanitized = sanitizeOperationId(seg)
63
- return sanitized.charAt(0).toUpperCase() + sanitized.slice(1)
64
- })
65
-
66
- const joined = parts.join('')
67
- return prefix + joined
68
- }
69
-
70
- function extractPathParams(path: string): string[] {
71
- const matches = path.match(/\{([^}]+)\}/g)
72
- if (matches === null) return []
73
- return matches.map((m) => sanitizeOperationId(m.slice(1, -1)))
74
- }
75
-
76
- /** Extract primary resource name from path (first static segment after stripping API prefix) */
77
- function primaryResource(path: string): string {
78
- // Strip /api/v{N}/ prefix or leading /
79
- const stripped = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '')
80
- const firstSegment = stripped.split('/')[0] ?? 'resource'
81
- // Remove any path param braces if somehow first seg is param
82
- return firstSegment.replace(/[{}]/g, '')
83
- }
84
-
85
- /** Convert tag to valid camelCase identifier, then singularize. e.g. page-selections → pageSelection */
86
- function toKeyFactoryName(resource: string): string {
87
- // Sanitize the resource to a valid camelCase identifier, then singularize
88
- const camel = sanitizeOperationId(resource)
89
- return camel.endsWith('s') ? camel.slice(0, -1) : camel
90
- }
91
-
92
- interface OperationMeta {
93
- funcName: string
94
- hookName: string
95
- method: SupportedMethod
96
- path: string
97
- pathParams: string[]
98
- hasBody: boolean
99
- bodyTypeName: string | undefined
100
- hasQueryParams: boolean
101
- hasRequiredQueryParams: boolean
102
- deprecated: boolean
103
- }
104
-
105
- function getBodyInfo(operation: OperationObject): {
106
- hasBody: boolean
107
- bodyTypeName: string | undefined
108
- } {
109
- const requestBody = operation.requestBody as
110
- | OpenAPIV3_1.RequestBodyObject
111
- | ReferenceObject
112
- | undefined
113
- if (requestBody === undefined) return { hasBody: false, bodyTypeName: undefined }
114
- if (isRef(requestBody)) return { hasBody: true, bodyTypeName: undefined }
115
-
116
- const rb = requestBody as OpenAPIV3_1.RequestBodyObject
117
- const content = rb.content as
118
- | Record<string, { schema?: OpenAPIV3_1.SchemaObject | ReferenceObject }>
119
- | undefined
120
- if (content === undefined) return { hasBody: true, bodyTypeName: undefined }
121
-
122
- // Check multipart first
123
- if (content['multipart/form-data'] !== undefined) {
124
- return { hasBody: true, bodyTypeName: undefined }
125
- }
126
-
127
- const jsonContent = content['application/json']
128
- if (jsonContent === undefined || jsonContent.schema === undefined) {
129
- return { hasBody: true, bodyTypeName: undefined }
130
- }
131
-
132
- const schema = jsonContent.schema
133
- if (isRef(schema)) {
134
- return { hasBody: true, bodyTypeName: refToName((schema as ReferenceObject).$ref) }
135
- }
136
-
137
- // Inline schema
138
- const s = schema as OpenAPIV3_1.SchemaObject
139
- if (s.type === 'object') {
140
- return { hasBody: true, bodyTypeName: 'Record<string, unknown>' }
141
- }
142
- return { hasBody: true, bodyTypeName: undefined }
143
- }
144
-
145
- // ── Key factory generation ─────────────────────────────────────────────────────
146
-
147
- interface KeyEntry {
148
- key: string // e.g. 'list' | 'detail' | operationId-derived
149
- funcName: string // function to use in key factory
150
- pathParams: string[]
151
- hasQueryParams: boolean
152
- hasRequiredQueryParams: boolean
153
- }
154
-
155
- function buildKeyFactory(resource: string, entries: KeyEntry[]): string {
156
- const factoryName = `${toKeyFactoryName(resource)}Keys`
157
- const lines: string[] = []
158
- lines.push(`export const ${factoryName} = {`)
159
- lines.push(` all: () => ['${resource}'] as const,`)
160
-
161
- for (const entry of entries) {
162
- const paramsOptional = !entry.hasRequiredQueryParams
163
- const paramsArg = paramsOptional
164
- ? `params?: Parameters<typeof ${entry.funcName}>[${entry.pathParams.length}]`
165
- : `params: Parameters<typeof ${entry.funcName}>[${entry.pathParams.length}]`
166
-
167
- // When multiple detail-level GET ops share the same resource (e.g. GET /items/{id}
168
- // and GET /items/{id}/usage), each gets a funcName-derived key instead of 'detail'.
169
- // Include the key name as a segment to prevent cache key collisions.
170
- // Single detail ops keep the canonical ['resource', id] shape (no breaking change).
171
- const keySegment =
172
- entry.pathParams.length > 0 && entry.key !== 'detail' ? `'${entry.key}', ` : ''
173
-
174
- if (entry.pathParams.length === 0 && entry.hasQueryParams) {
175
- // list: (params?) => ['resource', 'list', params]
176
- lines.push(
177
- ` ${entry.key}: (${paramsArg}) => ['${resource}', '${entry.key}', params] as const,`
178
- )
179
- } else if (entry.pathParams.length === 0 && !entry.hasQueryParams) {
180
- // list with no params
181
- lines.push(` ${entry.key}: () => ['${resource}', '${entry.key}'] as const,`)
182
- } else if (entry.pathParams.length === 1 && !entry.hasQueryParams) {
183
- // detail: (id) => ['resource', id] or getItemUsage: (id) => ['resource', 'getItemUsage', id]
184
- const param = entry.pathParams[0]!
185
- lines.push(
186
- ` ${entry.key}: (${param}: string) => ['${resource}', ${keySegment}${param}] as const,`
187
- )
188
- } else if (entry.pathParams.length === 1 && entry.hasQueryParams) {
189
- // detail with query params
190
- const param = entry.pathParams[0]!
191
- lines.push(
192
- ` ${entry.key}: (${param}: string, ${paramsArg}) => ['${resource}', ${keySegment}${param}, params] as const,`
193
- )
194
- } else if (!entry.hasQueryParams) {
195
- // multiple path params, no query params
196
- const paramList = entry.pathParams.map((p) => `${p}: string`).join(', ')
197
- const paramValues = entry.pathParams.join(', ')
198
- lines.push(
199
- ` ${entry.key}: (${paramList}) => ['${resource}', ${keySegment}${paramValues}] as const,`
200
- )
201
- } else {
202
- // multiple path params + query params
203
- const paramList = entry.pathParams.map((p) => `${p}: string`).join(', ')
204
- const paramValues = entry.pathParams.join(', ')
205
- lines.push(
206
- ` ${entry.key}: (${paramList}, ${paramsArg}) => ['${resource}', ${keySegment}${paramValues}, params] as const,`
207
- )
208
- }
209
- }
210
-
211
- lines.push(`}`)
212
- return lines.join('\n')
213
- }
214
-
215
- // ── Query hook generation ──────────────────────────────────────────────────────
216
-
217
- function buildQueryHook(
218
- op: OperationMeta,
219
- keyFactoryName: string,
220
- keyEntry: KeyEntry,
221
- staleTime: number,
222
- gcTime: number
223
- ): string {
224
- const lines: string[] = []
225
-
226
- const hasQueryParams = keyEntry.hasQueryParams
227
- const paramsRequired = keyEntry.hasRequiredQueryParams
228
- const pathParams = op.pathParams
229
- const hasPathParams = pathParams.length > 0
230
-
231
- // Build parameter list
232
- const sigParts: string[] = []
233
- for (const p of pathParams) {
234
- // Widen path param types to allow nullish values — enables auto-disable without !!id boilerplate
235
- sigParts.push(`${p}: string | undefined | null`)
236
- }
237
- if (hasQueryParams) {
238
- const paramsToken = paramsRequired ? 'params' : 'params?'
239
- sigParts.push(`${paramsToken}: Parameters<typeof ${op.funcName}>[${pathParams.length}]`)
240
- }
241
- sigParts.push(
242
- `options?: Omit<UseQueryOptions<Awaited<ReturnType<typeof ${op.funcName}>>, ApiError>, 'queryKey' | 'queryFn'>`
243
- )
244
-
245
- // Build queryKey call — use non-null assertions since enabled guard ensures non-null at runtime
246
- let queryKeyCall: string
247
- if (pathParams.length === 0 && hasQueryParams) {
248
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(params)`
249
- } else if (pathParams.length === 0 && !hasQueryParams) {
250
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}()`
251
- } else if (pathParams.length === 1 && !hasQueryParams) {
252
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${pathParams[0]}!)`
253
- } else if (pathParams.length === 1 && hasQueryParams) {
254
- // path param + query params
255
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${pathParams[0]}!, params)`
256
- } else if (!hasQueryParams) {
257
- // multiple path params, no query params
258
- const paramValues = pathParams.map((p) => `${p}!`).join(', ')
259
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${paramValues})`
260
- } else {
261
- // multiple path params + query params
262
- const paramValues = pathParams.map((p) => `${p}!`).join(', ')
263
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${paramValues}, params)`
264
- }
265
-
266
- // Build queryFn call — use non-null assertions
267
- let queryFnArgs: string[] = pathParams.map((p) => `${p}!`)
268
- if (hasQueryParams) queryFnArgs.push('params')
269
- const queryFnCall = `${op.funcName}(${queryFnArgs.join(', ')})`
270
-
271
- // Build enabled expression for path-param hooks
272
- const enabledExpr = hasPathParams
273
- ? `${pathParams.map((p) => `${p} != null`).join(' && ')} && (options?.enabled ?? true)`
274
- : undefined
275
-
276
- // @deprecated JSDoc on deprecated operations
277
- if (op.deprecated) {
278
- lines.push(`/** @deprecated */`)
279
- }
280
- lines.push(`export function ${op.hookName}(`)
281
- lines.push(` ${sigParts.join(',\n ')},`)
282
- lines.push(`) {`)
283
- lines.push(` return useQuery<Awaited<ReturnType<typeof ${op.funcName}>>, ApiError>({`)
284
- lines.push(` queryKey: ${queryKeyCall},`)
285
- lines.push(` queryFn: () => ${queryFnCall},`)
286
- lines.push(` staleTime: ${staleTime},`)
287
- lines.push(` gcTime: ${gcTime},`)
288
- if (enabledExpr !== undefined) {
289
- lines.push(` enabled: ${enabledExpr},`)
290
- }
291
- lines.push(` ...options,`)
292
- lines.push(` })`)
293
- lines.push(`}`)
294
-
295
- return lines.join('\n')
296
- }
297
-
298
- function buildSuspenseQueryHook(
299
- op: OperationMeta,
300
- keyFactoryName: string,
301
- keyEntry: KeyEntry,
302
- staleTime: number,
303
- gcTime: number
304
- ): string {
305
- const lines: string[] = []
306
-
307
- const hasQueryParams = keyEntry.hasQueryParams
308
- const paramsRequired = keyEntry.hasRequiredQueryParams
309
- const pathParams = op.pathParams
310
-
311
- // Suspense hooks require path params (no nullish widening — suspense doesn't support enabled)
312
- const sigParts: string[] = []
313
- for (const p of pathParams) {
314
- sigParts.push(`${p}: string`)
315
- }
316
- if (hasQueryParams) {
317
- const paramsToken = paramsRequired ? 'params' : 'params?'
318
- sigParts.push(`${paramsToken}: Parameters<typeof ${op.funcName}>[${pathParams.length}]`)
319
- }
320
- sigParts.push(
321
- `options?: Omit<UseSuspenseQueryOptions<Awaited<ReturnType<typeof ${op.funcName}>>, ApiError>, 'queryKey' | 'queryFn'>`
322
- )
323
-
324
- // Build queryKey call
325
- let queryKeyCall: string
326
- if (pathParams.length === 0 && hasQueryParams) {
327
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(params)`
328
- } else if (pathParams.length === 0 && !hasQueryParams) {
329
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}()`
330
- } else if (pathParams.length === 1 && !hasQueryParams) {
331
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${pathParams[0]})`
332
- } else if (pathParams.length === 1 && hasQueryParams) {
333
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${pathParams[0]}, params)`
334
- } else if (!hasQueryParams) {
335
- const paramValues = pathParams.join(', ')
336
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${paramValues})`
337
- } else {
338
- const paramValues = pathParams.join(', ')
339
- queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${paramValues}, params)`
340
- }
341
-
342
- // Build queryFn call
343
- const queryFnArgs: string[] = [...pathParams]
344
- if (hasQueryParams) queryFnArgs.push('params')
345
- const queryFnCall = `${op.funcName}(${queryFnArgs.join(', ')})`
346
-
347
- const suspenseHookName = `useSuspense${capitalize(op.funcName)}`
348
-
349
- if (op.deprecated) {
350
- lines.push(`/** @deprecated */`)
351
- }
352
- lines.push(`export function ${suspenseHookName}(`)
353
- lines.push(` ${sigParts.join(',\n ')},`)
354
- lines.push(`) {`)
355
- lines.push(` return useSuspenseQuery<Awaited<ReturnType<typeof ${op.funcName}>>, ApiError>({`)
356
- lines.push(` queryKey: ${queryKeyCall},`)
357
- lines.push(` queryFn: () => ${queryFnCall},`)
358
- lines.push(` staleTime: ${staleTime},`)
359
- lines.push(` gcTime: ${gcTime},`)
360
- lines.push(` ...options,`)
361
- lines.push(` })`)
362
- lines.push(`}`)
363
-
364
- return lines.join('\n')
365
- }
366
-
367
- // ── Mutation hook generation ───────────────────────────────────────────────────
368
-
369
- interface MutationInvalidateInfo {
370
- keyFactoryName: string
371
- detailKeyName: string | undefined
372
- }
373
-
374
- function buildMutationHook(
375
- op: OperationMeta,
376
- autoInvalidate: boolean,
377
- invalidateInfo: MutationInvalidateInfo | undefined
378
- ): string {
379
- const lines: string[] = []
380
- const { funcName, hookName, pathParams, hasBody, hasQueryParams } = op
381
-
382
- // Determine variables type and mutationFn
383
- // The client function argument order is: ...pathParams, body?, params?
384
- // We must match exactly what the generated client function expects.
385
- let variablesType: string
386
- let mutationFnBody: string
387
-
388
- // Compute the index of the params argument in the client function signature
389
- const paramsArgIndex = pathParams.length + (hasBody ? 1 : 0)
390
-
391
- if (pathParams.length === 0 && !hasBody && !hasQueryParams) {
392
- variablesType = 'void'
393
- mutationFnBody = `() => ${funcName}()`
394
- } else if (pathParams.length === 0 && !hasBody && hasQueryParams) {
395
- // params-only mutation: fn(params)
396
- variablesType = `Parameters<typeof ${funcName}>[0]`
397
- mutationFnBody = `(vars) => ${funcName}(vars)`
398
- } else if (pathParams.length === 0 && hasBody && !hasQueryParams) {
399
- // body-only mutation: fn(body)
400
- variablesType = `Parameters<typeof ${funcName}>[0]`
401
- mutationFnBody = `(vars) => ${funcName}(vars)`
402
- } else if (pathParams.length === 0 && hasBody && hasQueryParams) {
403
- // body + params: fn(body, params)
404
- variablesType = `{ body: Parameters<typeof ${funcName}>[0]; params: Parameters<typeof ${funcName}>[${paramsArgIndex}] }`
405
- mutationFnBody = `({ body, params }) => ${funcName}(body, params)`
406
- } else if (pathParams.length === 1 && !hasBody && !hasQueryParams) {
407
- variablesType = 'string'
408
- mutationFnBody = `(${pathParams[0]}) => ${funcName}(${pathParams[0]})`
409
- } else if (pathParams.length === 1 && !hasBody && hasQueryParams) {
410
- // 1 path param + params only
411
- const param = pathParams[0]!
412
- variablesType = `{ ${param}: string; params: Parameters<typeof ${funcName}>[${paramsArgIndex}] }`
413
- mutationFnBody = `({ ${param}, params }) => ${funcName}(${param}, params)`
414
- } else if (pathParams.length === 1 && hasBody && !hasQueryParams) {
415
- const param = pathParams[0]!
416
- variablesType = `{ ${param}: string; body: Parameters<typeof ${funcName}>[1] }`
417
- mutationFnBody = `({ ${param}, body }) => ${funcName}(${param}, body)`
418
- } else if (pathParams.length === 1 && hasBody && hasQueryParams) {
419
- // 1 path param + body + params
420
- const param = pathParams[0]!
421
- variablesType = `{ ${param}: string; body: Parameters<typeof ${funcName}>[1]; params: Parameters<typeof ${funcName}>[${paramsArgIndex}] }`
422
- mutationFnBody = `({ ${param}, body, params }) => ${funcName}(${param}, body, params)`
423
- } else if (pathParams.length > 1 && !hasBody && !hasQueryParams) {
424
- const fields = pathParams.map((p) => `${p}: string`).join('; ')
425
- variablesType = `{ ${fields} }`
426
- const destructured = pathParams.join(', ')
427
- mutationFnBody = `({ ${destructured} }) => ${funcName}(${destructured})`
428
- } else if (pathParams.length > 1 && !hasBody && hasQueryParams) {
429
- // multiple path params + params only
430
- const fields = pathParams.map((p) => `${p}: string`).join('; ')
431
- variablesType = `{ ${fields}; params: Parameters<typeof ${funcName}>[${paramsArgIndex}] }`
432
- const destructured = [...pathParams, 'params'].join(', ')
433
- mutationFnBody = `({ ${destructured} }) => ${funcName}(${[...pathParams, 'params'].join(', ')})`
434
- } else if (pathParams.length > 1 && hasBody && !hasQueryParams) {
435
- // multiple path params + body
436
- const fields = pathParams.map((p) => `${p}: string`).join('; ')
437
- variablesType = `{ ${fields}; body: Parameters<typeof ${funcName}>[${pathParams.length}] }`
438
- const destructured = [...pathParams, 'body'].join(', ')
439
- mutationFnBody = `({ ${destructured} }) => ${funcName}(${[...pathParams, 'body'].join(', ')})`
440
- } else {
441
- // multiple path params + body + params
442
- const fields = pathParams.map((p) => `${p}: string`).join('; ')
443
- variablesType = `{ ${fields}; body: Parameters<typeof ${funcName}>[${pathParams.length}]; params: Parameters<typeof ${funcName}>[${paramsArgIndex}] }`
444
- const destructured = [...pathParams, 'body', 'params'].join(', ')
445
- mutationFnBody = `({ ${destructured} }) => ${funcName}(${[...pathParams, 'body', 'params'].join(', ')})`
446
- }
447
-
448
- // @deprecated JSDoc on deprecated operations
449
- if (op.deprecated) {
450
- lines.push(`/** @deprecated */`)
451
- }
452
- lines.push(`export function ${hookName}(`)
453
- lines.push(
454
- ` options?: Omit<UseMutationOptions<Awaited<ReturnType<typeof ${funcName}>>, ApiError, ${variablesType}>, 'mutationFn'>,`
455
- )
456
- lines.push(`) {`)
457
-
458
- const shouldInvalidate = autoInvalidate && invalidateInfo !== undefined
459
- if (shouldInvalidate) {
460
- lines.push(` const queryClient = useQueryClient()`)
461
- }
462
-
463
- lines.push(
464
- ` return useMutation<Awaited<ReturnType<typeof ${funcName}>>, ApiError, ${variablesType}>({`
465
- )
466
- lines.push(` mutationFn: ${mutationFnBody},`)
467
- // Spread options first so caller can override mutationFn-unrelated options (e.g. retry, meta).
468
- // onSuccess must come AFTER the spread so the auto-invalidation logic always runs —
469
- // the caller's onSuccess is composed via options?.onSuccess?.(...args) inside.
470
- lines.push(` ...options,`)
471
-
472
- if (shouldInvalidate) {
473
- const { keyFactoryName, detailKeyName } = invalidateInfo
474
- const canInvalidateDetail =
475
- detailKeyName !== undefined &&
476
- pathParams.length >= 1 &&
477
- (op.method === 'put' || op.method === 'patch')
478
-
479
- // Determine how to reference the first path param from variables in onSuccess
480
- let detailIdRef: string
481
- if (pathParams.length === 1 && !hasBody && !hasQueryParams) {
482
- // variablesType is 'string' — variables IS the id directly
483
- detailIdRef = 'variables'
484
- } else if (pathParams.length >= 1) {
485
- // variablesType is an object — access via property name
486
- detailIdRef = `variables.${pathParams[0]}`
487
- } else {
488
- detailIdRef = 'variables'
489
- }
490
-
491
- lines.push(` onSuccess: (...args) => {`)
492
- if (canInvalidateDetail) {
493
- lines.push(` const [, variables] = args`)
494
- }
495
- lines.push(` queryClient.invalidateQueries({ queryKey: ${keyFactoryName}.all() })`)
496
- if (canInvalidateDetail) {
497
- lines.push(
498
- ` queryClient.invalidateQueries({ queryKey: ${keyFactoryName}.${detailKeyName}(${detailIdRef}) })`
499
- )
500
- }
501
- lines.push(` options?.onSuccess?.(...args)`)
502
- lines.push(` },`)
503
- }
504
-
505
- lines.push(` })`)
506
- lines.push(`}`)
507
-
508
- return lines.join('\n')
509
- }
510
-
511
- // ── hasQueryParams detection ───────────────────────────────────────────────────
512
-
513
- function operationHasQueryParams(operation: OperationObject): boolean {
514
- const params = operation.parameters as
515
- | (OpenAPIV3_1.ParameterObject | ReferenceObject)[]
516
- | undefined
517
- if (params === undefined) return false
518
- return params.some((p) => {
519
- if (isRef(p)) return false
520
- const param = p as OpenAPIV3_1.ParameterObject
521
- return param.in === 'query'
522
- })
523
- }
524
-
525
- /**
526
- * Converts a raw operationId into a valid camelCase JS identifier.
527
- * Mirrors the logic in openapi-gen/src/plugins/client.ts.
528
- * e.g. "actions/add-custom-labels" → "actionsAddCustomLabels"
529
- * e.g. "delete-storedPaymentMethods-id" → "deleteStoredPaymentMethodsId"
530
- */
531
- function sanitizeOperationId(id: string): string {
532
- const parts = id
533
- .replace(/'/g, '')
534
- .split(/[^a-zA-Z0-9]+/)
535
- .filter(Boolean)
536
- if (parts.length === 0) return 'unknown'
537
- const [first = '', ...rest] = parts
538
- const camel =
539
- first.charAt(0).toLowerCase() +
540
- first.slice(1) +
541
- rest.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')
542
- if (/^[0-9]/.test(camel)) return `_${camel}`
543
- const RESERVED = new Set([
544
- 'break',
545
- 'case',
546
- 'catch',
547
- 'class',
548
- 'const',
549
- 'continue',
550
- 'debugger',
551
- 'default',
552
- 'delete',
553
- 'do',
554
- 'else',
555
- 'export',
556
- 'extends',
557
- 'finally',
558
- 'for',
559
- 'function',
560
- 'if',
561
- 'import',
562
- 'in',
563
- 'instanceof',
564
- 'let',
565
- 'new',
566
- 'return',
567
- 'static',
568
- 'super',
569
- 'switch',
570
- 'this',
571
- 'throw',
572
- 'try',
573
- 'typeof',
574
- 'var',
575
- 'void',
576
- 'while',
577
- 'with',
578
- 'yield',
579
- ])
580
- return RESERVED.has(camel) ? `_${camel}` : camel
581
- }
582
-
583
- /** Returns true if any query parameter has required: true */
584
- function operationHasRequiredQueryParams(operation: OperationObject): boolean {
585
- const params = operation.parameters as
586
- | (OpenAPIV3_1.ParameterObject | ReferenceObject)[]
587
- | undefined
588
- if (params === undefined) return false
589
- return params.some((p) => {
590
- if (isRef(p)) return false
591
- const param = p as OpenAPIV3_1.ParameterObject
592
- return param.in === 'query' && param.required === true
593
- })
594
- }
595
-
596
- // ── Main generator ─────────────────────────────────────────────────────────────
597
-
598
- export function generateHooks(
599
- spec: OpenAPIV3_1.Document,
600
- options: HookGenOptions
601
- ): { filename: string; content: string } {
602
- const { staleTime, gcTime, suspense = false, autoInvalidate = false } = options
603
- const paths = spec.paths as Record<string, Record<string, OperationObject>> | undefined
604
-
605
- // Collect all operations
606
- const operations: OperationMeta[] = []
607
-
608
- if (paths !== undefined) {
609
- for (const [path, pathItem] of Object.entries(paths)) {
610
- for (const method of SUPPORTED_METHODS) {
611
- const operation = pathItem[method] as OperationObject | undefined
612
- if (operation === undefined) continue
613
-
614
- let funcName: string
615
- if (operation.operationId !== undefined) {
616
- funcName = sanitizeOperationId(operation.operationId)
617
- } else {
618
- funcName = deriveOperationName(method, path)
619
- }
620
-
621
- const hookName = 'use' + capitalize(funcName)
622
- const pathParams = extractPathParams(path)
623
- const { hasBody, bodyTypeName } = getBodyInfo(operation)
624
- const hasQueryParams = operationHasQueryParams(operation)
625
- const hasRequiredQueryParams = operationHasRequiredQueryParams(operation)
626
- const deprecated = operation.deprecated === true
627
-
628
- operations.push({
629
- funcName,
630
- hookName,
631
- method,
632
- path,
633
- pathParams,
634
- hasBody,
635
- bodyTypeName,
636
- hasQueryParams,
637
- hasRequiredQueryParams,
638
- deprecated,
639
- })
640
- }
641
- }
642
- }
643
-
644
- // Separate GET vs mutation operations
645
- const getOps = operations.filter((op) => op.method === 'get')
646
- const mutationOps = operations.filter((op) => op.method !== 'get')
647
-
648
- // Group GET ops by resource for key factories
649
- const resourceToGetOps = new Map<string, OperationMeta[]>()
650
- for (const op of getOps) {
651
- const resource = primaryResource(op.path)
652
- const existing = resourceToGetOps.get(resource) ?? []
653
- existing.push(op)
654
- resourceToGetOps.set(resource, existing)
655
- }
656
-
657
- // Build key factories and track which key factory + entry each GET op uses
658
- const opToKeyInfo = new Map<string, { factoryName: string; keyEntry: KeyEntry }>()
659
- const keyFactoryBlocks: string[] = []
660
-
661
- for (const [resource, ops] of resourceToGetOps.entries()) {
662
- const factoryName = `${toKeyFactoryName(resource)}Keys`
663
-
664
- // Determine entry keys — list vs detail, or operationId-derived if ambiguous
665
- const listOps = ops.filter((op) => op.pathParams.length === 0)
666
- const detailOps = ops.filter((op) => op.pathParams.length > 0)
667
-
668
- const entries: KeyEntry[] = []
669
-
670
- if (listOps.length === 1) {
671
- const op = listOps[0]!
672
- const entry: KeyEntry = {
673
- key: 'list',
674
- funcName: op.funcName,
675
- pathParams: [],
676
- hasQueryParams: op.hasQueryParams,
677
- hasRequiredQueryParams: op.hasRequiredQueryParams,
678
- }
679
- entries.push(entry)
680
- opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
681
- } else if (listOps.length > 1) {
682
- for (const op of listOps) {
683
- const entry: KeyEntry = {
684
- key: op.funcName,
685
- funcName: op.funcName,
686
- pathParams: [],
687
- hasQueryParams: op.hasQueryParams,
688
- hasRequiredQueryParams: op.hasRequiredQueryParams,
689
- }
690
- entries.push(entry)
691
- opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
692
- }
693
- }
694
-
695
- if (detailOps.length === 1) {
696
- const op = detailOps[0]!
697
- const entry: KeyEntry = {
698
- key: 'detail',
699
- funcName: op.funcName,
700
- pathParams: op.pathParams,
701
- hasQueryParams: op.hasQueryParams,
702
- hasRequiredQueryParams: op.hasRequiredQueryParams,
703
- }
704
- entries.push(entry)
705
- opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
706
- } else if (detailOps.length > 1) {
707
- for (const op of detailOps) {
708
- const entry: KeyEntry = {
709
- key: op.funcName,
710
- funcName: op.funcName,
711
- pathParams: op.pathParams,
712
- hasQueryParams: op.hasQueryParams,
713
- hasRequiredQueryParams: op.hasRequiredQueryParams,
714
- }
715
- entries.push(entry)
716
- opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
717
- }
718
- }
719
-
720
- keyFactoryBlocks.push(buildKeyFactory(resource, entries))
721
- }
722
-
723
- // Build query hooks (and suspense variants if enabled)
724
- const queryHookBlocks: string[] = []
725
- for (const op of getOps) {
726
- const info = opToKeyInfo.get(op.funcName)
727
- if (info === undefined) continue
728
- const { factoryName, keyEntry } = info
729
- const resource = primaryResource(op.path)
730
- const resourceOverride = options.overrides?.[resource]
731
- const effectiveStaleTime = resourceOverride?.staleTime ?? staleTime
732
- const effectiveGcTime = resourceOverride?.gcTime ?? gcTime
733
- queryHookBlocks.push(
734
- buildQueryHook(op, factoryName, keyEntry, effectiveStaleTime, effectiveGcTime)
735
- )
736
- if (suspense) {
737
- queryHookBlocks.push(
738
- buildSuspenseQueryHook(op, factoryName, keyEntry, effectiveStaleTime, effectiveGcTime)
739
- )
740
- }
741
- }
742
-
743
- // Build resource → detailKeyName map for auto-invalidate
744
- // detailKeyName is 'detail' when exactly one detail op exists (canonical name),
745
- // or undefined when zero or multiple detail ops exist (ambiguous — skip detail invalidation)
746
- const resourceDetailKeyName = new Map<string, string | undefined>()
747
- for (const [resource, ops] of resourceToGetOps.entries()) {
748
- const detailOps = ops.filter((op) => op.pathParams.length > 0)
749
- resourceDetailKeyName.set(resource, detailOps.length === 1 ? 'detail' : undefined)
750
- }
751
-
752
- // Build mutation hooks
753
- const mutationHookBlocks: string[] = []
754
- for (const op of mutationOps) {
755
- let invalidateInfo: MutationInvalidateInfo | undefined
756
- if (autoInvalidate) {
757
- const resource = primaryResource(op.path)
758
- // Only invalidate if a key factory exists for this resource (i.e. it has at least one GET op).
759
- // A mutation-only resource has no key factory — emitting invalidateQueries against it would
760
- // reference an undefined variable and crash at runtime.
761
- if (resourceToGetOps.has(resource)) {
762
- const keyFactoryName = `${toKeyFactoryName(resource)}Keys`
763
- const detailKeyName = resourceDetailKeyName.get(resource)
764
- invalidateInfo = { keyFactoryName, detailKeyName }
765
- }
766
- }
767
- mutationHookBlocks.push(buildMutationHook(op, autoInvalidate, invalidateInfo))
768
- }
769
-
770
- // Determine which react-query exports to import
771
- const needsUseQuery = getOps.length > 0
772
- const needsUseMutation = mutationOps.length > 0
773
- const needsUseSuspenseQuery = suspense && getOps.length > 0
774
- const needsUseQueryClient = autoInvalidate && needsUseMutation
775
- const rqImports: string[] = []
776
- if (needsUseQuery) rqImports.push('useQuery', 'type UseQueryOptions')
777
- if (needsUseSuspenseQuery) rqImports.push('useSuspenseQuery', 'type UseSuspenseQueryOptions')
778
- if (needsUseMutation) rqImports.push('useMutation', 'type UseMutationOptions')
779
- if (needsUseQueryClient) rqImports.push('useQueryClient')
780
-
781
- // Collect all function names for client import
782
- const allFuncNames = operations.map((op) => op.funcName).sort()
783
-
784
- // Build file content
785
- const lines: string[] = []
786
- lines.push('// This file is auto-generated by @codewithagents/openapi-react-query — do not edit')
787
- lines.push('')
788
- if (rqImports.length > 0) {
789
- lines.push(`import { ${rqImports.join(', ')} } from '@tanstack/react-query'`)
790
- }
791
- if (allFuncNames.length > 0) {
792
- lines.push(`import { ${allFuncNames.join(', ')}, type ApiError } from './client.js'`)
793
- } else {
794
- lines.push(`import { type ApiError } from './client.js'`)
795
- }
796
- lines.push('')
797
-
798
- if (keyFactoryBlocks.length > 0) {
799
- lines.push('// ── Query key factories ──────────────────────────────────────')
800
- lines.push('')
801
- lines.push(keyFactoryBlocks.join('\n\n'))
802
- lines.push('')
803
- }
804
-
805
- if (queryHookBlocks.length > 0) {
806
- lines.push('// ── Queries ──────────────────────────────────────────────────')
807
- lines.push('')
808
- lines.push(queryHookBlocks.join('\n\n'))
809
- lines.push('')
810
- }
811
-
812
- if (mutationHookBlocks.length > 0) {
813
- lines.push('// ── Mutations ────────────────────────────────────────────────')
814
- lines.push('')
815
- lines.push(mutationHookBlocks.join('\n\n'))
816
- lines.push('')
817
- }
818
-
819
- return {
820
- filename: 'hooks.ts',
821
- content: lines.join('\n'),
822
- }
823
- }