@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,186 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { loadConfig, validateConfigPath, validateOutputPath, validateInputPath } from '../config.js'
6
+
7
+ describe('loadConfig', () => {
8
+ let tmpDir: string
9
+
10
+ beforeEach(() => {
11
+ tmpDir = mkdtempSync(join(tmpdir(), 'openapi-react-query-config-'))
12
+ })
13
+
14
+ afterEach(() => {
15
+ rmSync(tmpDir, { recursive: true, force: true })
16
+ })
17
+
18
+ function writeConfig(content: unknown) {
19
+ writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), JSON.stringify(content))
20
+ }
21
+
22
+ it('loads a valid minimal config', async () => {
23
+ writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks' })
24
+ const config = await loadConfig(tmpDir)
25
+ expect(config.input_openapi).toBe('openapi.json')
26
+ expect(config.output).toBe('src/hooks')
27
+ expect(config.stale_time).toBeUndefined()
28
+ expect(config.gc_time).toBeUndefined()
29
+ })
30
+
31
+ it('loads a full config with all fields', async () => {
32
+ writeConfig({
33
+ input_openapi: 'api/openapi.json',
34
+ output: 'src/generated',
35
+ stale_time: 5000,
36
+ gc_time: 600000,
37
+ })
38
+ const config = await loadConfig(tmpDir)
39
+ expect(config.input_openapi).toBe('api/openapi.json')
40
+ expect(config.output).toBe('src/generated')
41
+ expect(config.stale_time).toBe(5000)
42
+ expect(config.gc_time).toBe(600000)
43
+ })
44
+
45
+ it('throws when config file is missing', async () => {
46
+ await expect(loadConfig(tmpDir)).rejects.toThrow('Config file not found')
47
+ })
48
+
49
+ it('throws when config is not valid JSON', async () => {
50
+ writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), 'not json {{{')
51
+ await expect(loadConfig(tmpDir)).rejects.toThrow('not valid JSON')
52
+ })
53
+
54
+ it('throws when config is not an object', async () => {
55
+ writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), '"just a string"')
56
+ await expect(loadConfig(tmpDir)).rejects.toThrow('JSON object')
57
+ })
58
+
59
+ it('throws when input_openapi is missing', async () => {
60
+ writeConfig({ output: 'src/hooks' })
61
+ await expect(loadConfig(tmpDir)).rejects.toThrow('input_openapi')
62
+ })
63
+
64
+ it('throws when output is missing', async () => {
65
+ writeConfig({ input_openapi: 'openapi.json' })
66
+ await expect(loadConfig(tmpDir)).rejects.toThrow('output')
67
+ })
68
+
69
+ it('throws when stale_time is not a number', async () => {
70
+ writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', stale_time: 'fast' })
71
+ await expect(loadConfig(tmpDir)).rejects.toThrow('stale_time')
72
+ })
73
+
74
+ it('throws when gc_time is not a number', async () => {
75
+ writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', gc_time: true })
76
+ await expect(loadConfig(tmpDir)).rejects.toThrow('gc_time')
77
+ })
78
+
79
+ it('ignores unknown config fields', async () => {
80
+ writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', unknown_field: 'ignored' })
81
+ const config = await loadConfig(tmpDir)
82
+ expect(config.input_openapi).toBe('openapi.json')
83
+ })
84
+
85
+ it('accepts a config loaded via explicit configPath', async () => {
86
+ const configFile = join(tmpDir, 'custom.config.json')
87
+ writeFileSync(configFile, JSON.stringify({ input_openapi: 'openapi.json', output: 'src/hooks' }))
88
+ const config = await loadConfig(tmpDir, configFile)
89
+ expect(config.input_openapi).toBe('openapi.json')
90
+ expect(config.output).toBe('src/hooks')
91
+ })
92
+
93
+ it('rejects explicit configPath that is not .json', async () => {
94
+ const configFile = join(tmpDir, 'config.ts')
95
+ writeFileSync(configFile, JSON.stringify({ input_openapi: 'openapi.json', output: 'src/hooks' }))
96
+ await expect(loadConfig(tmpDir, configFile)).rejects.toThrow('Config file must be a .json file')
97
+ })
98
+ })
99
+
100
+ describe('config security validation', () => {
101
+ describe('validateConfigPath', () => {
102
+ it('rejects non-.json config file extension', () => {
103
+ expect(() => validateConfigPath('/project/config.ts')).toThrow('Config file must be a .json file')
104
+ })
105
+
106
+ it('rejects .yaml extension', () => {
107
+ expect(() => validateConfigPath('/project/config.yaml')).toThrow('Config file must be a .json file')
108
+ })
109
+
110
+ it('accepts .json extension', () => {
111
+ expect(() => validateConfigPath('/project/config.json')).not.toThrow()
112
+ })
113
+
114
+ it('accepts nested .json path', () => {
115
+ expect(() => validateConfigPath('/Users/someone/project/my-tool.config.json')).not.toThrow()
116
+ })
117
+ })
118
+
119
+ describe('validateOutputPath', () => {
120
+ it('rejects output path in /etc', () => {
121
+ expect(() => validateOutputPath('/etc/generated')).toThrow('system directory')
122
+ })
123
+
124
+ it('rejects output path in /usr', () => {
125
+ expect(() => validateOutputPath('/usr/local/generated')).toThrow('system directory')
126
+ })
127
+
128
+ it('rejects output path in /bin', () => {
129
+ expect(() => validateOutputPath('/bin/generated')).toThrow('system directory')
130
+ })
131
+
132
+ it('rejects output path in /sys', () => {
133
+ expect(() => validateOutputPath('/sys/something')).toThrow('system directory')
134
+ })
135
+
136
+ it('rejects output path in /proc', () => {
137
+ expect(() => validateOutputPath('/proc/1/generated')).toThrow('system directory')
138
+ })
139
+
140
+ it('rejects output path in /dev', () => {
141
+ expect(() => validateOutputPath('/dev/null')).toThrow('system directory')
142
+ })
143
+
144
+ it('rejects output path in /boot', () => {
145
+ expect(() => validateOutputPath('/boot/generated')).toThrow('system directory')
146
+ })
147
+
148
+ it('accepts normal project-relative resolved path', () => {
149
+ expect(() => validateOutputPath('/Users/someone/project/generated')).not.toThrow()
150
+ })
151
+
152
+ it('accepts absolute path within home directory', () => {
153
+ expect(() => validateOutputPath('/Users/someone/myproject/src/hooks')).not.toThrow()
154
+ })
155
+
156
+ it('accepts dist/api style path', () => {
157
+ expect(() => validateOutputPath('/home/user/project/dist/hooks')).not.toThrow()
158
+ })
159
+ })
160
+
161
+ describe('validateInputPath', () => {
162
+ it('rejects input spec from /proc', () => {
163
+ expect(() => validateInputPath('/proc/1/fd/0')).toThrow('system directory')
164
+ })
165
+
166
+ it('rejects input spec from /etc', () => {
167
+ expect(() => validateInputPath('/etc/passwd')).toThrow('system directory')
168
+ })
169
+
170
+ it('rejects input spec from /dev', () => {
171
+ expect(() => validateInputPath('/dev/random')).toThrow('system directory')
172
+ })
173
+
174
+ it('rejects input spec from /usr', () => {
175
+ expect(() => validateInputPath('/usr/share/openapi.json')).toThrow('system directory')
176
+ })
177
+
178
+ it('accepts input spec in home directory', () => {
179
+ expect(() => validateInputPath('/Users/someone/project/openapi.json')).not.toThrow()
180
+ })
181
+
182
+ it('accepts input spec in /tmp', () => {
183
+ expect(() => validateInputPath('/tmp/openapi.json')).not.toThrow()
184
+ })
185
+ })
186
+ })
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import type { OpenAPIV3_1 } from 'openapi-types'
5
+ import { generateHooks } from '../plugins/hooks.js'
6
+
7
+ // Load fixture directly (no parseSpec — avoids needing openapi-gen dist built)
8
+ const fixturePath = join(import.meta.dirname, '../../__fixtures__/specs/task-hooks.json')
9
+ const spec = JSON.parse(readFileSync(fixturePath, 'utf-8')) as OpenAPIV3_1.Document
10
+
11
+ const { filename, content } = generateHooks(spec, { staleTime: 30_000, gcTime: 300_000 })
12
+
13
+ describe('generateHooks — task-hooks.json fixture', () => {
14
+ it('filename is hooks.ts', () => {
15
+ expect(filename).toBe('hooks.ts')
16
+ })
17
+
18
+ it('contains auto-generated header comment', () => {
19
+ expect(content).toContain(
20
+ '// This file is auto-generated by @codewithagents/openapi-react-query — do not edit',
21
+ )
22
+ })
23
+
24
+ it('imports useQuery and useMutation from @tanstack/react-query', () => {
25
+ expect(content).toContain("from '@tanstack/react-query'")
26
+ expect(content).toContain('useQuery')
27
+ expect(content).toContain('useMutation')
28
+ })
29
+
30
+ it('imports all client functions and ApiError from ./client.js', () => {
31
+ expect(content).toContain("from './client.js'")
32
+ expect(content).toContain('listTasks')
33
+ expect(content).toContain('getTask')
34
+ expect(content).toContain('createTask')
35
+ expect(content).toContain('updateTask')
36
+ expect(content).toContain('deleteTask')
37
+ expect(content).toContain('type ApiError')
38
+ })
39
+
40
+ it('generates taskKeys factory with all(), list(), and detail() entries', () => {
41
+ expect(content).toContain('export const taskKeys')
42
+ expect(content).toContain("all: () => ['tasks'] as const")
43
+ expect(content).toContain("list:")
44
+ expect(content).toContain("detail:")
45
+ })
46
+
47
+ it('useListTasks uses taskKeys.list(params) as queryKey', () => {
48
+ expect(content).toContain('useListTasks')
49
+ expect(content).toContain('queryKey: taskKeys.list(params)')
50
+ })
51
+
52
+ it('useListTasks has staleTime: 30_000', () => {
53
+ expect(content).toContain('staleTime: 30000,')
54
+ })
55
+
56
+ it('useListTasks has gcTime: 300_000', () => {
57
+ expect(content).toContain('gcTime: 300000,')
58
+ })
59
+
60
+ it('useGetTask accepts id as positional arg and uses taskKeys.detail(id)', () => {
61
+ expect(content).toContain('useGetTask')
62
+ expect(content).toContain('id: string')
63
+ expect(content).toContain('queryKey: taskKeys.detail(id)')
64
+ })
65
+
66
+ it('useCreateTask variables type is Parameters<typeof createTask>[0]', () => {
67
+ expect(content).toContain('useCreateTask')
68
+ expect(content).toContain('Parameters<typeof createTask>[0]')
69
+ })
70
+
71
+ it('useUpdateTask variables type has { id: string; body: Parameters<typeof updateTask>[1] }', () => {
72
+ expect(content).toContain('useUpdateTask')
73
+ expect(content).toContain('{ id: string; body: Parameters<typeof updateTask>[1] }')
74
+ })
75
+
76
+ it('useUpdateTask mutationFn destructures { id, body }', () => {
77
+ expect(content).toContain('({ id, body }) => updateTask(id, body)')
78
+ })
79
+
80
+ it('useDeleteTask variables type is string', () => {
81
+ // deleteTask has one path param and no body → variables type is string
82
+ expect(content).toContain('useDeleteTask')
83
+ // The mutation signature should include 'string' as variables type for deleteTask
84
+ // We check that the mutationFn destructures id directly
85
+ expect(content).toContain('(id) => deleteTask(id)')
86
+ })
87
+
88
+ it('useDeleteTask mutationFn is (id) => deleteTask(id)', () => {
89
+ expect(content).toContain('mutationFn: (id) => deleteTask(id)')
90
+ })
91
+
92
+ it('staleTime and gcTime are overridable via options spread', () => {
93
+ // ...options must appear AFTER staleTime and gcTime in the useQuery call
94
+ const staleTimeIdx = content.indexOf('staleTime: 30000,')
95
+ const gcTimeIdx = content.indexOf('gcTime: 300000,')
96
+ const spreadIdx = content.indexOf('...options,', gcTimeIdx)
97
+ expect(staleTimeIdx).toBeGreaterThan(-1)
98
+ expect(gcTimeIdx).toBeGreaterThan(staleTimeIdx)
99
+ expect(spreadIdx).toBeGreaterThan(gcTimeIdx)
100
+ })
101
+ })
package/src/cli.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { resolve, dirname } from 'node:path'
3
+ import { generate } from './generator.js'
4
+
5
+ const args = process.argv.slice(2)
6
+ const configIdx = args.indexOf('--config')
7
+
8
+ let cwd = process.cwd()
9
+ let configFile: string | undefined
10
+
11
+ if (configIdx !== -1) {
12
+ const next = args[configIdx + 1]
13
+ if (next === undefined || next.startsWith('--')) {
14
+ console.error('Error: --config requires a file path argument')
15
+ console.error('Usage: openapi-react-query [--config <path-to-config.json>]')
16
+ process.exit(1)
17
+ }
18
+ configFile = resolve(cwd, next)
19
+ // Relative paths inside the config resolve from the config file's directory,
20
+ // not from the shell's CWD. This matches how most tooling works.
21
+ cwd = dirname(configFile)
22
+ }
23
+
24
+ generate(cwd, configFile).catch((err: Error) => {
25
+ console.error(`Error: ${err.message}`)
26
+ process.exit(1)
27
+ })
package/src/config.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ export interface ReactQueryConfig {
5
+ /** Path to OpenAPI 3.1 spec file (JSON or YAML) */
6
+ input_openapi: string
7
+ /** Directory to write generated files */
8
+ output: string
9
+ /** staleTime in ms for all useQuery hooks (default: 0) */
10
+ stale_time?: number
11
+ /** gcTime in ms for all useQuery hooks (default: 300000) */
12
+ gc_time?: number
13
+ }
14
+
15
+ const FORBIDDEN_OUTPUT_PREFIXES = [
16
+ '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64',
17
+ '/sys', '/proc', '/dev', '/boot', '/run',
18
+ 'C:\\Windows', 'C:\\Program Files',
19
+ ]
20
+
21
+ const FORBIDDEN_INPUT_PREFIXES = [
22
+ '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64',
23
+ '/sys', '/proc', '/dev',
24
+ 'C:\\Windows', 'C:\\Program Files',
25
+ ]
26
+
27
+ export function validateConfigPath(configPath: string): void {
28
+ // Must end in .json — prevents accidentally loading non-config files
29
+ if (!configPath.endsWith('.json')) {
30
+ throw new Error(`Config file must be a .json file, got: ${configPath}`)
31
+ }
32
+ }
33
+
34
+ export function validateOutputPath(resolvedOutput: string): void {
35
+ const normalized = resolvedOutput.replace(/\\/g, '/')
36
+ for (const forbidden of FORBIDDEN_OUTPUT_PREFIXES) {
37
+ if (normalized.startsWith(forbidden.replace(/\\/g, '/'))) {
38
+ throw new Error(
39
+ `Output path resolves to a system directory: "${resolvedOutput}". ` +
40
+ `This looks like a misconfiguration — please check your config file.`
41
+ )
42
+ }
43
+ }
44
+ }
45
+
46
+ export function validateInputPath(resolvedInput: string): void {
47
+ const normalized = resolvedInput.replace(/\\/g, '/')
48
+ for (const forbidden of FORBIDDEN_INPUT_PREFIXES) {
49
+ if (normalized.startsWith(forbidden.replace(/\\/g, '/'))) {
50
+ throw new Error(
51
+ `Input spec path resolves to a system directory: "${resolvedInput}". ` +
52
+ `This looks like a misconfiguration — please check your config file.`
53
+ )
54
+ }
55
+ }
56
+ }
57
+
58
+ export async function loadConfig(cwd: string, configPath?: string): Promise<ReactQueryConfig> {
59
+ const resolvedConfigPath = configPath ?? join(cwd, 'openapi-react-query.config.json')
60
+
61
+ // Security: validate config path extension when explicitly provided
62
+ if (configPath !== undefined) {
63
+ validateConfigPath(configPath)
64
+ }
65
+
66
+ let raw: string
67
+ try {
68
+ raw = await readFile(resolvedConfigPath, 'utf-8')
69
+ } catch {
70
+ throw new Error(`Config file not found: ${resolvedConfigPath}`)
71
+ }
72
+
73
+ let parsed: unknown
74
+ try {
75
+ parsed = JSON.parse(raw)
76
+ } catch {
77
+ throw new Error(`Config file is not valid JSON: ${resolvedConfigPath}`)
78
+ }
79
+
80
+ if (typeof parsed !== 'object' || parsed === null) {
81
+ throw new Error('Config must be a JSON object')
82
+ }
83
+
84
+ const config = parsed as Record<string, unknown>
85
+
86
+ if (typeof config['input_openapi'] !== 'string' || !config['input_openapi']) {
87
+ throw new Error('Config missing required field: "input_openapi" (path to OpenAPI 3.1 spec)')
88
+ }
89
+ if (typeof config['output'] !== 'string' || !config['output']) {
90
+ throw new Error('Config missing required field: "output" (output directory)')
91
+ }
92
+ if (config['stale_time'] !== undefined && typeof config['stale_time'] !== 'number') {
93
+ throw new Error('"stale_time" must be a number (milliseconds)')
94
+ }
95
+ if (config['gc_time'] !== undefined && typeof config['gc_time'] !== 'number') {
96
+ throw new Error('"gc_time" must be a number (milliseconds)')
97
+ }
98
+
99
+ // Security: validate resolved input and output paths
100
+ const resolvedInput = resolve(cwd, config['input_openapi'] as string)
101
+ const resolvedOutput = resolve(cwd, config['output'] as string)
102
+ validateInputPath(resolvedInput)
103
+ validateOutputPath(resolvedOutput)
104
+
105
+ return {
106
+ input_openapi: config['input_openapi'] as string,
107
+ output: config['output'] as string,
108
+ stale_time: config['stale_time'] as number | undefined,
109
+ gc_time: config['gc_time'] as number | undefined,
110
+ }
111
+ }
@@ -0,0 +1,20 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+ import { parseSpec } from '@codewithagents/openapi-gen'
4
+ import { loadConfig } from './config.js'
5
+ import { generateHooks } from './plugins/hooks.js'
6
+
7
+ export async function generate(cwd: string, configPath?: string): Promise<void> {
8
+ const config = await loadConfig(cwd, configPath)
9
+ const inputPath = resolve(cwd, config.input_openapi)
10
+ const outputDir = resolve(cwd, config.output)
11
+ const spec = await parseSpec(inputPath)
12
+ const file = generateHooks(spec, {
13
+ staleTime: config.stale_time ?? 0,
14
+ gcTime: config.gc_time ?? 300_000,
15
+ })
16
+ await mkdir(outputDir, { recursive: true })
17
+ const filePath = join(outputDir, file.filename)
18
+ await writeFile(filePath, file.content, 'utf-8')
19
+ console.log(`✓ ${file.filename}`)
20
+ }