@codewithagents/openapi-react-query 0.1.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CLAUDE.md +26 -0
  3. package/__fixtures__/specs/task-hooks.json +155 -0
  4. package/coverage/lcov-report/base.css +224 -0
  5. package/coverage/lcov-report/block-navigation.js +87 -0
  6. package/coverage/lcov-report/favicon.png +0 -0
  7. package/coverage/lcov-report/index.html +131 -0
  8. package/coverage/lcov-report/prettify.css +1 -0
  9. package/coverage/lcov-report/prettify.js +2 -0
  10. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  11. package/coverage/lcov-report/sorter.js +210 -0
  12. package/coverage/lcov-report/src/config.ts.html +418 -0
  13. package/coverage/lcov-report/src/index.html +116 -0
  14. package/coverage/lcov-report/src/plugins/hooks.ts.html +1435 -0
  15. package/coverage/lcov-report/src/plugins/index.html +116 -0
  16. package/coverage/lcov.info +484 -0
  17. package/dist/cli.d.ts +3 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +24 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/config.d.ts +15 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +85 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/generator.d.ts +2 -0
  26. package/dist/generator.d.ts.map +1 -0
  27. package/dist/generator.js +20 -0
  28. package/dist/generator.js.map +1 -0
  29. package/dist/plugins/hooks.d.ts +10 -0
  30. package/dist/plugins/hooks.d.ts.map +1 -0
  31. package/dist/plugins/hooks.js +370 -0
  32. package/dist/plugins/hooks.js.map +1 -0
  33. package/package.json +33 -0
  34. package/src/__tests__/config.test.ts +186 -0
  35. package/src/__tests__/hooks.test.ts +101 -0
  36. package/src/cli.ts +27 -0
  37. package/src/config.ts +111 -0
  38. package/src/generator.ts +20 -0
  39. package/src/plugins/hooks.ts +450 -0
  40. package/tsconfig.build.json +10 -0
  41. package/tsconfig.json +9 -0
  42. package/vitest.config.ts +12 -0
@@ -0,0 +1,450 @@
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
+ }
10
+
11
+ // ── Helpers ───────────────────────────────────────────────────────────────────
12
+
13
+ function isRef(obj: unknown): obj is ReferenceObject {
14
+ return typeof obj === 'object' && obj !== null && '$ref' in obj
15
+ }
16
+
17
+ function refToName(ref: string): string {
18
+ const parts = ref.split('/')
19
+ return parts[parts.length - 1]!
20
+ }
21
+
22
+ function capitalize(s: string): string {
23
+ return s.charAt(0).toUpperCase() + s.slice(1)
24
+ }
25
+
26
+ const SUPPORTED_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
27
+ type SupportedMethod = (typeof SUPPORTED_METHODS)[number]
28
+
29
+ // Mirrors deriveOperationName from openapi-gen/src/plugins/client.ts
30
+ function deriveOperationName(method: string, path: string): string {
31
+ const prefixMap: Record<string, string> = {
32
+ get: 'get',
33
+ post: 'create',
34
+ put: 'update',
35
+ patch: 'patch',
36
+ delete: 'delete',
37
+ }
38
+ const prefix = prefixMap[method] ?? method
39
+
40
+ // Strip /api/v1/ prefix
41
+ let segments = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '')
42
+
43
+ const parts = segments.split('/').map((seg) => {
44
+ if (seg.startsWith('{') && seg.endsWith('}')) {
45
+ const name = seg.slice(1, -1)
46
+ return 'By' + name.charAt(0).toUpperCase() + name.slice(1)
47
+ }
48
+ return seg.charAt(0).toUpperCase() + seg.slice(1)
49
+ })
50
+
51
+ const joined = parts.join('')
52
+ return prefix + joined
53
+ }
54
+
55
+ function extractPathParams(path: string): string[] {
56
+ const matches = path.match(/\{([^}]+)\}/g)
57
+ if (matches === null) return []
58
+ return matches.map((m) => m.slice(1, -1))
59
+ }
60
+
61
+ /** Extract primary resource name from path (first static segment after stripping API prefix) */
62
+ function primaryResource(path: string): string {
63
+ // Strip /api/v{N}/ prefix or leading /
64
+ const stripped = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '')
65
+ const firstSegment = stripped.split('/')[0] ?? 'resource'
66
+ // Remove any path param braces if somehow first seg is param
67
+ return firstSegment.replace(/[{}]/g, '')
68
+ }
69
+
70
+ /** singularize resource name for key factory naming, e.g. tasks → task */
71
+ function toKeyFactoryName(resource: string): string {
72
+ // simple pluralization removal: trailing 's'
73
+ return resource.endsWith('s') ? resource.slice(0, -1) : resource
74
+ }
75
+
76
+ interface OperationMeta {
77
+ funcName: string
78
+ hookName: string
79
+ method: SupportedMethod
80
+ path: string
81
+ pathParams: string[]
82
+ hasBody: boolean
83
+ bodyTypeName: string | undefined
84
+ }
85
+
86
+ function getBodyInfo(operation: OperationObject): { hasBody: boolean; bodyTypeName: string | undefined } {
87
+ const requestBody = operation.requestBody as OpenAPIV3_1.RequestBodyObject | ReferenceObject | undefined
88
+ if (requestBody === undefined) return { hasBody: false, bodyTypeName: undefined }
89
+ if (isRef(requestBody)) return { hasBody: true, bodyTypeName: undefined }
90
+
91
+ const rb = requestBody as OpenAPIV3_1.RequestBodyObject
92
+ const content = rb.content as Record<string, { schema?: OpenAPIV3_1.SchemaObject | ReferenceObject }> | undefined
93
+ if (content === undefined) return { hasBody: true, bodyTypeName: undefined }
94
+
95
+ // Check multipart first
96
+ if (content['multipart/form-data'] !== undefined) {
97
+ return { hasBody: true, bodyTypeName: undefined }
98
+ }
99
+
100
+ const jsonContent = content['application/json']
101
+ if (jsonContent === undefined || jsonContent.schema === undefined) {
102
+ return { hasBody: true, bodyTypeName: undefined }
103
+ }
104
+
105
+ const schema = jsonContent.schema
106
+ if (isRef(schema)) {
107
+ return { hasBody: true, bodyTypeName: refToName((schema as ReferenceObject).$ref) }
108
+ }
109
+
110
+ // Inline schema
111
+ const s = schema as OpenAPIV3_1.SchemaObject
112
+ if (s.type === 'object') {
113
+ return { hasBody: true, bodyTypeName: 'Record<string, unknown>' }
114
+ }
115
+ return { hasBody: true, bodyTypeName: undefined }
116
+ }
117
+
118
+ // ── Key factory generation ─────────────────────────────────────────────────────
119
+
120
+ interface KeyEntry {
121
+ key: string // e.g. 'list' | 'detail' | operationId-derived
122
+ funcName: string // function to use in key factory
123
+ pathParams: string[]
124
+ hasQueryParams: boolean
125
+ }
126
+
127
+ function buildKeyFactory(resource: string, entries: KeyEntry[]): string {
128
+ const factoryName = `${toKeyFactoryName(resource)}Keys`
129
+ const lines: string[] = []
130
+ lines.push(`export const ${factoryName} = {`)
131
+ lines.push(` all: () => ['${resource}'] as const,`)
132
+
133
+ for (const entry of entries) {
134
+ if (entry.pathParams.length === 0 && entry.hasQueryParams) {
135
+ // list: (params?) => ['resource', 'list', params]
136
+ lines.push(` ${entry.key}: (params?: Parameters<typeof ${entry.funcName}>[0]) => ['${resource}', '${entry.key}', params] as const,`)
137
+ } else if (entry.pathParams.length === 0 && !entry.hasQueryParams) {
138
+ // list with no params
139
+ lines.push(` ${entry.key}: () => ['${resource}', '${entry.key}'] as const,`)
140
+ } else if (entry.pathParams.length === 1) {
141
+ // detail: (id: string) => ['resource', id]
142
+ const param = entry.pathParams[0]!
143
+ lines.push(` ${entry.key}: (${param}: string) => ['${resource}', ${param}] as const,`)
144
+ } else {
145
+ // multiple path params
146
+ const paramList = entry.pathParams.map((p) => `${p}: string`).join(', ')
147
+ const paramValues = entry.pathParams.join(', ')
148
+ lines.push(` ${entry.key}: (${paramList}) => ['${resource}', ${paramValues}] as const,`)
149
+ }
150
+ }
151
+
152
+ lines.push(`}`)
153
+ return lines.join('\n')
154
+ }
155
+
156
+ // ── Query hook generation ──────────────────────────────────────────────────────
157
+
158
+ function buildQueryHook(
159
+ op: OperationMeta,
160
+ keyFactoryName: string,
161
+ keyEntry: KeyEntry,
162
+ staleTime: number,
163
+ gcTime: number,
164
+ ): string {
165
+ const lines: string[] = []
166
+
167
+ const hasQueryParams = keyEntry.hasQueryParams
168
+ const pathParams = op.pathParams
169
+
170
+ // Build parameter list
171
+ const sigParts: string[] = []
172
+ for (const p of pathParams) {
173
+ sigParts.push(`${p}: string`)
174
+ }
175
+ if (hasQueryParams) {
176
+ sigParts.push(`params?: Parameters<typeof ${op.funcName}>[${pathParams.length}]`)
177
+ }
178
+ sigParts.push(`options?: Omit<UseQueryOptions<Awaited<ReturnType<typeof ${op.funcName}>>, ApiError>, 'queryKey' | 'queryFn'>`)
179
+
180
+ // Build queryKey call
181
+ let queryKeyCall: string
182
+ if (pathParams.length === 0 && hasQueryParams) {
183
+ queryKeyCall = `${keyFactoryName}.${keyEntry.key}(params)`
184
+ } else if (pathParams.length === 0 && !hasQueryParams) {
185
+ queryKeyCall = `${keyFactoryName}.${keyEntry.key}()`
186
+ } else if (pathParams.length === 1 && !hasQueryParams) {
187
+ queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${pathParams[0]})`
188
+ } else if (pathParams.length === 1 && hasQueryParams) {
189
+ // path param + query params
190
+ queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${pathParams[0]}, params)`
191
+ } else {
192
+ const paramValues = pathParams.join(', ')
193
+ queryKeyCall = `${keyFactoryName}.${keyEntry.key}(${paramValues})`
194
+ }
195
+
196
+ // Build queryFn call
197
+ let queryFnArgs: string[] = [...pathParams]
198
+ if (hasQueryParams) queryFnArgs.push('params')
199
+ const queryFnCall = `${op.funcName}(${queryFnArgs.join(', ')})`
200
+
201
+ lines.push(`export function ${op.hookName}(`)
202
+ lines.push(` ${sigParts.join(',\n ')},`)
203
+ lines.push(`) {`)
204
+ lines.push(` return useQuery<Awaited<ReturnType<typeof ${op.funcName}>>, ApiError>({`)
205
+ lines.push(` queryKey: ${queryKeyCall},`)
206
+ lines.push(` queryFn: () => ${queryFnCall},`)
207
+ lines.push(` staleTime: ${staleTime},`)
208
+ lines.push(` gcTime: ${gcTime},`)
209
+ lines.push(` ...options,`)
210
+ lines.push(` })`)
211
+ lines.push(`}`)
212
+
213
+ return lines.join('\n')
214
+ }
215
+
216
+ // ── Mutation hook generation ───────────────────────────────────────────────────
217
+
218
+ function buildMutationHook(op: OperationMeta): string {
219
+ const lines: string[] = []
220
+ const { funcName, hookName, pathParams, hasBody } = op
221
+
222
+ // Determine variables type and mutationFn
223
+ let variablesType: string
224
+ let mutationFnBody: string
225
+
226
+ if (pathParams.length === 0 && !hasBody) {
227
+ variablesType = 'void'
228
+ mutationFnBody = `() => ${funcName}()`
229
+ } else if (pathParams.length === 0 && hasBody) {
230
+ variablesType = `Parameters<typeof ${funcName}>[0]`
231
+ mutationFnBody = `(vars) => ${funcName}(vars)`
232
+ } else if (pathParams.length === 1 && !hasBody) {
233
+ variablesType = 'string'
234
+ mutationFnBody = `(${pathParams[0]}) => ${funcName}(${pathParams[0]})`
235
+ } else if (pathParams.length === 1 && hasBody) {
236
+ const param = pathParams[0]!
237
+ variablesType = `{ ${param}: string; body: Parameters<typeof ${funcName}>[1] }`
238
+ mutationFnBody = `({ ${param}, body }) => ${funcName}(${param}, body)`
239
+ } else if (pathParams.length > 1 && !hasBody) {
240
+ const fields = pathParams.map((p) => `${p}: string`).join('; ')
241
+ variablesType = `{ ${fields} }`
242
+ const destructured = pathParams.join(', ')
243
+ mutationFnBody = `({ ${destructured} }) => ${funcName}(${destructured})`
244
+ } else {
245
+ // multiple path params + body
246
+ const fields = pathParams.map((p) => `${p}: string`).join('; ')
247
+ variablesType = `{ ${fields}; body: Parameters<typeof ${funcName}>[${pathParams.length}] }`
248
+ const destructured = [...pathParams, 'body'].join(', ')
249
+ mutationFnBody = `({ ${destructured} }) => ${funcName}(${[...pathParams, 'body'].join(', ')})`
250
+ }
251
+
252
+ lines.push(`export function ${hookName}(`)
253
+ lines.push(` options?: Omit<UseMutationOptions<Awaited<ReturnType<typeof ${funcName}>>, ApiError, ${variablesType}>, 'mutationFn'>,`)
254
+ lines.push(`) {`)
255
+ lines.push(` return useMutation<Awaited<ReturnType<typeof ${funcName}>>, ApiError, ${variablesType}>({`)
256
+ lines.push(` mutationFn: ${mutationFnBody},`)
257
+ lines.push(` ...options,`)
258
+ lines.push(` })`)
259
+ lines.push(`}`)
260
+
261
+ return lines.join('\n')
262
+ }
263
+
264
+ // ── hasQueryParams detection ───────────────────────────────────────────────────
265
+
266
+ function operationHasQueryParams(operation: OperationObject): boolean {
267
+ const params = operation.parameters as (OpenAPIV3_1.ParameterObject | ReferenceObject)[] | undefined
268
+ if (params === undefined) return false
269
+ return params.some((p) => {
270
+ if (isRef(p)) return false
271
+ const param = p as OpenAPIV3_1.ParameterObject
272
+ return param.in === 'query'
273
+ })
274
+ }
275
+
276
+ // ── Main generator ─────────────────────────────────────────────────────────────
277
+
278
+ export function generateHooks(
279
+ spec: OpenAPIV3_1.Document,
280
+ options: HookGenOptions,
281
+ ): { filename: string; content: string } {
282
+ const { staleTime, gcTime } = options
283
+ const paths = spec.paths as Record<string, Record<string, OperationObject>> | undefined
284
+
285
+ // Collect all operations
286
+ const operations: OperationMeta[] = []
287
+
288
+ if (paths !== undefined) {
289
+ for (const [path, pathItem] of Object.entries(paths)) {
290
+ for (const method of SUPPORTED_METHODS) {
291
+ const operation = pathItem[method] as OperationObject | undefined
292
+ if (operation === undefined) continue
293
+
294
+ let funcName: string
295
+ if (operation.operationId !== undefined) {
296
+ const id = operation.operationId
297
+ funcName = id.charAt(0).toLowerCase() + id.slice(1)
298
+ } else {
299
+ funcName = deriveOperationName(method, path)
300
+ }
301
+
302
+ const hookName = 'use' + capitalize(funcName)
303
+ const pathParams = extractPathParams(path)
304
+ const { hasBody, bodyTypeName } = getBodyInfo(operation)
305
+
306
+ operations.push({
307
+ funcName,
308
+ hookName,
309
+ method,
310
+ path,
311
+ pathParams,
312
+ hasBody,
313
+ bodyTypeName,
314
+ })
315
+ }
316
+ }
317
+ }
318
+
319
+ // Separate GET vs mutation operations
320
+ const getOps = operations.filter((op) => op.method === 'get')
321
+ const mutationOps = operations.filter((op) => op.method !== 'get')
322
+
323
+ // Group GET ops by resource for key factories
324
+ const resourceToGetOps = new Map<string, OperationMeta[]>()
325
+ for (const op of getOps) {
326
+ const resource = primaryResource(op.path)
327
+ const existing = resourceToGetOps.get(resource) ?? []
328
+ existing.push(op)
329
+ resourceToGetOps.set(resource, existing)
330
+ }
331
+
332
+ // Build key factories and track which key factory + entry each GET op uses
333
+ const opToKeyInfo = new Map<string, { factoryName: string; keyEntry: KeyEntry }>()
334
+ const keyFactoryBlocks: string[] = []
335
+
336
+ for (const [resource, ops] of resourceToGetOps.entries()) {
337
+ const factoryName = `${toKeyFactoryName(resource)}Keys`
338
+
339
+ // Determine entry keys — list vs detail, or operationId-derived if ambiguous
340
+ const listOps = ops.filter((op) => op.pathParams.length === 0)
341
+ const detailOps = ops.filter((op) => op.pathParams.length > 0)
342
+
343
+ const entries: KeyEntry[] = []
344
+
345
+ if (listOps.length === 1) {
346
+ const op = listOps[0]!
347
+ const hasQueryParams = operationHasQueryParams(
348
+ (paths![op.path] as Record<string, OperationObject>)[op.method]!,
349
+ )
350
+ const entry: KeyEntry = { key: 'list', funcName: op.funcName, pathParams: [], hasQueryParams }
351
+ entries.push(entry)
352
+ opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
353
+ } else if (listOps.length > 1) {
354
+ for (const op of listOps) {
355
+ const hasQueryParams = operationHasQueryParams(
356
+ (paths![op.path] as Record<string, OperationObject>)[op.method]!,
357
+ )
358
+ const entry: KeyEntry = { key: op.funcName, funcName: op.funcName, pathParams: [], hasQueryParams }
359
+ entries.push(entry)
360
+ opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
361
+ }
362
+ }
363
+
364
+ if (detailOps.length === 1) {
365
+ const op = detailOps[0]!
366
+ const hasQueryParams = operationHasQueryParams(
367
+ (paths![op.path] as Record<string, OperationObject>)[op.method]!,
368
+ )
369
+ const entry: KeyEntry = { key: 'detail', funcName: op.funcName, pathParams: op.pathParams, hasQueryParams }
370
+ entries.push(entry)
371
+ opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
372
+ } else if (detailOps.length > 1) {
373
+ for (const op of detailOps) {
374
+ const hasQueryParams = operationHasQueryParams(
375
+ (paths![op.path] as Record<string, OperationObject>)[op.method]!,
376
+ )
377
+ const entry: KeyEntry = { key: op.funcName, funcName: op.funcName, pathParams: op.pathParams, hasQueryParams }
378
+ entries.push(entry)
379
+ opToKeyInfo.set(op.funcName, { factoryName, keyEntry: entry })
380
+ }
381
+ }
382
+
383
+ keyFactoryBlocks.push(buildKeyFactory(resource, entries))
384
+ }
385
+
386
+ // Build query hooks
387
+ const queryHookBlocks: string[] = []
388
+ for (const op of getOps) {
389
+ const info = opToKeyInfo.get(op.funcName)
390
+ if (info === undefined) continue
391
+ const { factoryName, keyEntry } = info
392
+ queryHookBlocks.push(buildQueryHook(op, factoryName, keyEntry, staleTime, gcTime))
393
+ }
394
+
395
+ // Build mutation hooks
396
+ const mutationHookBlocks: string[] = []
397
+ for (const op of mutationOps) {
398
+ mutationHookBlocks.push(buildMutationHook(op))
399
+ }
400
+
401
+ // Determine which react-query exports to import
402
+ const needsUseQuery = getOps.length > 0
403
+ const needsUseMutation = mutationOps.length > 0
404
+ const rqImports: string[] = []
405
+ if (needsUseQuery) rqImports.push('useQuery', 'type UseQueryOptions')
406
+ if (needsUseMutation) rqImports.push('useMutation', 'type UseMutationOptions')
407
+
408
+ // Collect all function names for client import
409
+ const allFuncNames = operations.map((op) => op.funcName).sort()
410
+
411
+ // Build file content
412
+ const lines: string[] = []
413
+ lines.push('// This file is auto-generated by @codewithagents/openapi-react-query — do not edit')
414
+ lines.push('')
415
+ if (rqImports.length > 0) {
416
+ lines.push(`import { ${rqImports.join(', ')} } from '@tanstack/react-query'`)
417
+ }
418
+ if (allFuncNames.length > 0) {
419
+ lines.push(`import { ${allFuncNames.join(', ')}, type ApiError } from './client.js'`)
420
+ } else {
421
+ lines.push(`import { type ApiError } from './client.js'`)
422
+ }
423
+ lines.push('')
424
+
425
+ if (keyFactoryBlocks.length > 0) {
426
+ lines.push('// ── Query key factories ──────────────────────────────────────')
427
+ lines.push('')
428
+ lines.push(keyFactoryBlocks.join('\n\n'))
429
+ lines.push('')
430
+ }
431
+
432
+ if (queryHookBlocks.length > 0) {
433
+ lines.push('// ── Queries ──────────────────────────────────────────────────')
434
+ lines.push('')
435
+ lines.push(queryHookBlocks.join('\n\n'))
436
+ lines.push('')
437
+ }
438
+
439
+ if (mutationHookBlocks.length > 0) {
440
+ lines.push('// ── Mutations ────────────────────────────────────────────────')
441
+ lines.push('')
442
+ lines.push(mutationHookBlocks.join('\n\n'))
443
+ lines.push('')
444
+ }
445
+
446
+ return {
447
+ filename: 'hooks.ts',
448
+ content: lines.join('\n'),
449
+ }
450
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src"],
9
+ "exclude": ["src/**/*.test.ts"]
10
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ coverage: {
6
+ provider: 'v8',
7
+ include: ['src/**/*.ts'],
8
+ exclude: ['src/**/*.test.ts', 'src/cli.ts', 'src/generator.ts'],
9
+ reporter: ['text', 'lcov'],
10
+ },
11
+ },
12
+ })