@pattern-stack/frontend-patterns 0.2.0-alpha.0 → 0.2.0-alpha.1
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.
- package/cli/commands/generate-hooks.ts +13 -4
- package/cli/src/codegen/openapi/__tests__/naming-utils.test.js +367 -0
- package/cli/src/codegen/openapi/client-generator.js +87 -19
- package/cli/src/codegen/openapi/confidence-scorer.js +93 -0
- package/cli/src/codegen/openapi/hook-config.js +48 -0
- package/cli/src/codegen/openapi/hook-generator.js +100 -62
- package/cli/src/codegen/openapi/naming-constants.js +98 -0
- package/cli/src/codegen/openapi/naming-utils.js +149 -0
- package/package.json +1 -1
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
import { promises as fs } from 'fs'
|
|
10
10
|
import { join, dirname } from 'path'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
11
12
|
import { loadOpenAPISpec, parseOpenAPI } from '../../src/codegen/openapi/parser.js'
|
|
12
13
|
import { generateTypes } from '../../src/codegen/openapi/type-generator.js'
|
|
13
14
|
import { generateAPIClient } from '../../src/codegen/openapi/client-generator.js'
|
|
14
15
|
import { generateHooks } from '../../src/codegen/openapi/hook-generator.js'
|
|
15
16
|
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
18
|
+
const __dirname = dirname(__filename)
|
|
19
|
+
|
|
16
20
|
interface GenerateHooksOptions {
|
|
17
21
|
output: string
|
|
18
22
|
prefix?: string
|
|
@@ -121,6 +125,11 @@ export async function generateHooksCommand(source: string, options: GenerateHook
|
|
|
121
125
|
await writeFile(join(options.output, 'hooks', 'keys.ts'), hooks.keys)
|
|
122
126
|
await writeFile(join(options.output, 'hooks', 'types.ts'), hooks.types)
|
|
123
127
|
await writeFile(join(options.output, 'hooks', 'index.ts'), hooks.index)
|
|
128
|
+
|
|
129
|
+
// Copy bulk-types.ts to output directory (referenced by hooks/types.ts)
|
|
130
|
+
const bulkTypesPath = join(__dirname, '../../src/codegen/openapi/bulk-types.ts')
|
|
131
|
+
const bulkTypesContent = await fs.readFile(bulkTypesPath, 'utf8')
|
|
132
|
+
await writeFile(join(options.output, 'bulk-types.ts'), bulkTypesContent)
|
|
124
133
|
|
|
125
134
|
// Write confidence report if available
|
|
126
135
|
if (hooks.report) {
|
|
@@ -154,10 +163,10 @@ export { queryKeys } from './hooks'`
|
|
|
154
163
|
|
|
155
164
|
await writeFile(join(options.output, 'index.ts'), mainIndex)
|
|
156
165
|
|
|
157
|
-
// Generate usage example
|
|
166
|
+
// Generate usage example (as .tsx since it contains JSX)
|
|
158
167
|
const usageExample = generateUsageExample(spec.info.title, options)
|
|
159
|
-
await writeFile(join(options.output, 'example.
|
|
160
|
-
|
|
168
|
+
await writeFile(join(options.output, 'example.tsx'), usageExample)
|
|
169
|
+
|
|
161
170
|
console.log()
|
|
162
171
|
console.log('✅ Generation complete!')
|
|
163
172
|
console.log(`📦 Generated files in: ${options.output}`)
|
|
@@ -165,7 +174,7 @@ export { queryKeys } from './hooks'`
|
|
|
165
174
|
console.log('🚀 Quick start:')
|
|
166
175
|
console.log(`import { createAPIClient, useGetUsers } from './${options.output}'`)
|
|
167
176
|
console.log()
|
|
168
|
-
console.log('💡 See example.
|
|
177
|
+
console.log('💡 See example.tsx for detailed usage instructions')
|
|
169
178
|
|
|
170
179
|
} catch (error) {
|
|
171
180
|
console.error('❌ Generation failed:')
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { singularize, pluralize, isPlural } from '../naming-utils.js';
|
|
2
|
+
|
|
3
|
+
describe('singularize()', () => {
|
|
4
|
+
describe('regular plurals', () => {
|
|
5
|
+
it('should convert cats to cat', () => {
|
|
6
|
+
expect(singularize('cats')).toBe('cat');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should convert dogs to dog', () => {
|
|
10
|
+
expect(singularize('dogs')).toBe('dog');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should convert users to user', () => {
|
|
14
|
+
expect(singularize('users')).toBe('user');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should convert accounts to account', () => {
|
|
18
|
+
expect(singularize('accounts')).toBe('account');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('-ies plurals', () => {
|
|
23
|
+
it('should convert activities to activity', () => {
|
|
24
|
+
expect(singularize('activities')).toBe('activity');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should convert categories to category', () => {
|
|
28
|
+
expect(singularize('categories')).toBe('category');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should convert companies to company', () => {
|
|
32
|
+
expect(singularize('companies')).toBe('company');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should convert opportunities to opportunity', () => {
|
|
36
|
+
expect(singularize('opportunities')).toBe('opportunity');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('-es plurals', () => {
|
|
41
|
+
it('should convert boxes to box', () => {
|
|
42
|
+
expect(singularize('boxes')).toBe('box');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should convert watches to watch', () => {
|
|
46
|
+
expect(singularize('watches')).toBe('watch');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should convert dishes to dish', () => {
|
|
50
|
+
expect(singularize('dishes')).toBe('dish');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should convert benches to bench', () => {
|
|
54
|
+
expect(singularize('benches')).toBe('bench');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('-ses plurals (tricky cases)', () => {
|
|
59
|
+
it('should convert addresses to address', () => {
|
|
60
|
+
expect(singularize('addresses')).toBe('address');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should convert businesses to business', () => {
|
|
64
|
+
expect(singularize('businesses')).toBe('business');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should convert processes to process', () => {
|
|
68
|
+
expect(singularize('processes')).toBe('process');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('irregular plurals', () => {
|
|
73
|
+
it('should convert people to person', () => {
|
|
74
|
+
expect(singularize('people')).toBe('person');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should convert children to child', () => {
|
|
78
|
+
expect(singularize('children')).toBe('child');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should convert men to man', () => {
|
|
82
|
+
expect(singularize('men')).toBe('man');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should convert women to woman', () => {
|
|
86
|
+
expect(singularize('women')).toBe('woman');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('already singular', () => {
|
|
91
|
+
it('should keep data as data', () => {
|
|
92
|
+
expect(singularize('data')).toBe('data');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should keep metadata as metadata', () => {
|
|
96
|
+
expect(singularize('metadata')).toBe('metadata');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should keep user as user', () => {
|
|
100
|
+
expect(singularize('user')).toBe('user');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should keep account as account', () => {
|
|
104
|
+
expect(singularize('account')).toBe('account');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('edge cases', () => {
|
|
109
|
+
it('should keep status as status', () => {
|
|
110
|
+
expect(singularize('status')).toBe('status');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should keep class as class', () => {
|
|
114
|
+
expect(singularize('class')).toBe('class');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should keep bus as bus', () => {
|
|
118
|
+
expect(singularize('bus')).toBe('bus');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should convert pass to pass (not pas)', () => {
|
|
122
|
+
expect(singularize('pass')).toBe('pass');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should handle empty string', () => {
|
|
126
|
+
expect(singularize('')).toBe('');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle single character', () => {
|
|
130
|
+
expect(singularize('a')).toBe('a');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('pluralize()', () => {
|
|
136
|
+
describe('regular plurals', () => {
|
|
137
|
+
it('should convert cat to cats', () => {
|
|
138
|
+
expect(pluralize('cat')).toBe('cats');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should convert dog to dogs', () => {
|
|
142
|
+
expect(pluralize('dog')).toBe('dogs');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should convert user to users', () => {
|
|
146
|
+
expect(pluralize('user')).toBe('users');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should convert account to accounts', () => {
|
|
150
|
+
expect(pluralize('account')).toBe('accounts');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('-y endings (consonant + y)', () => {
|
|
155
|
+
it('should convert activity to activities', () => {
|
|
156
|
+
expect(pluralize('activity')).toBe('activities');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should convert category to categories', () => {
|
|
160
|
+
expect(pluralize('category')).toBe('categories');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should convert company to companies', () => {
|
|
164
|
+
expect(pluralize('company')).toBe('companies');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should convert opportunity to opportunities', () => {
|
|
168
|
+
expect(pluralize('opportunity')).toBe('opportunities');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('vowel + y endings', () => {
|
|
173
|
+
it('should convert day to days', () => {
|
|
174
|
+
expect(pluralize('day')).toBe('days');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should convert key to keys', () => {
|
|
178
|
+
expect(pluralize('key')).toBe('keys');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should convert boy to boys', () => {
|
|
182
|
+
expect(pluralize('boy')).toBe('boys');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should convert array to arrays', () => {
|
|
186
|
+
expect(pluralize('array')).toBe('arrays');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('-x, -ch, -sh, -s, -ss endings', () => {
|
|
191
|
+
it('should convert box to boxes', () => {
|
|
192
|
+
expect(pluralize('box')).toBe('boxes');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should convert watch to watches', () => {
|
|
196
|
+
expect(pluralize('watch')).toBe('watches');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should convert dish to dishes', () => {
|
|
200
|
+
expect(pluralize('dish')).toBe('dishes');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should convert bench to benches', () => {
|
|
204
|
+
expect(pluralize('bench')).toBe('benches');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should convert address to addresses', () => {
|
|
208
|
+
expect(pluralize('address')).toBe('addresses');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should convert business to businesses', () => {
|
|
212
|
+
expect(pluralize('business')).toBe('businesses');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should convert class to classes', () => {
|
|
216
|
+
expect(pluralize('class')).toBe('classes');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('irregular plurals', () => {
|
|
221
|
+
it('should convert person to people', () => {
|
|
222
|
+
expect(pluralize('person')).toBe('people');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should convert child to children', () => {
|
|
226
|
+
expect(pluralize('child')).toBe('children');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should convert man to men', () => {
|
|
230
|
+
expect(pluralize('man')).toBe('men');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should convert woman to women', () => {
|
|
234
|
+
expect(pluralize('woman')).toBe('women');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('already plural (no double-pluralize)', () => {
|
|
239
|
+
it('should keep accounts as accounts', () => {
|
|
240
|
+
expect(pluralize('accounts')).toBe('accounts');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should keep users as users', () => {
|
|
244
|
+
expect(pluralize('users')).toBe('users');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should keep activities as activities', () => {
|
|
248
|
+
expect(pluralize('activities')).toBe('activities');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should keep boxes as boxes', () => {
|
|
252
|
+
expect(pluralize('boxes')).toBe('boxes');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('edge cases', () => {
|
|
257
|
+
it('should handle empty string', () => {
|
|
258
|
+
expect(pluralize('')).toBe('');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should handle single character', () => {
|
|
262
|
+
expect(pluralize('a')).toBe('as');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should keep status as status (special case)', () => {
|
|
266
|
+
expect(pluralize('status')).toBe('status');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('isPlural()', () => {
|
|
272
|
+
describe('should return true for plural words', () => {
|
|
273
|
+
it('should identify cats as plural', () => {
|
|
274
|
+
expect(isPlural('cats')).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should identify activities as plural', () => {
|
|
278
|
+
expect(isPlural('activities')).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should identify boxes as plural', () => {
|
|
282
|
+
expect(isPlural('boxes')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should identify watches as plural', () => {
|
|
286
|
+
expect(isPlural('watches')).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should identify addresses as plural', () => {
|
|
290
|
+
expect(isPlural('addresses')).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should identify people as plural', () => {
|
|
294
|
+
expect(isPlural('people')).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should identify children as plural', () => {
|
|
298
|
+
expect(isPlural('children')).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should identify users as plural', () => {
|
|
302
|
+
expect(isPlural('users')).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should identify accounts as plural', () => {
|
|
306
|
+
expect(isPlural('accounts')).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('should return false for singular words', () => {
|
|
311
|
+
it('should identify cat as singular', () => {
|
|
312
|
+
expect(isPlural('cat')).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should identify activity as singular', () => {
|
|
316
|
+
expect(isPlural('activity')).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should identify box as singular', () => {
|
|
320
|
+
expect(isPlural('box')).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should identify user as singular', () => {
|
|
324
|
+
expect(isPlural('user')).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should identify account as singular', () => {
|
|
328
|
+
expect(isPlural('account')).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('should return false for edge cases', () => {
|
|
333
|
+
it('should identify status as singular', () => {
|
|
334
|
+
expect(isPlural('status')).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should identify class as singular', () => {
|
|
338
|
+
expect(isPlural('class')).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should identify bus as singular', () => {
|
|
342
|
+
expect(isPlural('bus')).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should identify process as singular', () => {
|
|
346
|
+
expect(isPlural('process')).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should identify data as singular', () => {
|
|
350
|
+
expect(isPlural('data')).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should identify metadata as singular', () => {
|
|
354
|
+
expect(isPlural('metadata')).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('should handle empty/minimal input', () => {
|
|
359
|
+
it('should return false for empty string', () => {
|
|
360
|
+
expect(isPlural('')).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should return false for single character', () => {
|
|
364
|
+
expect(isPlural('a')).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Part of FRO-12: API Client Generator
|
|
8
8
|
*/
|
|
9
|
+
import { singularize, pluralize, isPlural } from './naming-utils.js';
|
|
10
|
+
import { AUTH_ACTIONS, HEALTH_ENDPOINTS, USER_PROFILE_ENDPOINTS, SINGLETON_RESOURCES, COLLECTION_ACTIONS } from './naming-constants.js';
|
|
11
|
+
|
|
9
12
|
export class APIClientGenerator {
|
|
10
13
|
options;
|
|
11
14
|
constructor(options = {}) {
|
|
@@ -39,7 +42,7 @@ export class APIClientGenerator {
|
|
|
39
42
|
generateAxiosClient() {
|
|
40
43
|
return `/**
|
|
41
44
|
* Axios API Client
|
|
42
|
-
*
|
|
45
|
+
*
|
|
43
46
|
* Auto-generated API client with interceptors and error handling
|
|
44
47
|
*/
|
|
45
48
|
|
|
@@ -146,7 +149,7 @@ export class APIClient {
|
|
|
146
149
|
generateFetchClient() {
|
|
147
150
|
return `/**
|
|
148
151
|
* Fetch API Client
|
|
149
|
-
*
|
|
152
|
+
*
|
|
150
153
|
* Auto-generated API client using native fetch with error handling
|
|
151
154
|
*/
|
|
152
155
|
|
|
@@ -166,7 +169,7 @@ export class APIClient {
|
|
|
166
169
|
): Promise<T> {
|
|
167
170
|
try {
|
|
168
171
|
const fullUrl = this.buildUrl(url, options.params)
|
|
169
|
-
|
|
172
|
+
|
|
170
173
|
const fetchOptions: RequestInit = {
|
|
171
174
|
method,
|
|
172
175
|
headers: {
|
|
@@ -205,7 +208,7 @@ export class APIClient {
|
|
|
205
208
|
|
|
206
209
|
private buildUrl(path: string, params?: Record<string, any>): string {
|
|
207
210
|
const url = new URL(path, this.config.baseUrl)
|
|
208
|
-
|
|
211
|
+
|
|
209
212
|
if (params) {
|
|
210
213
|
Object.entries(params).forEach(([key, value]) => {
|
|
211
214
|
if (value !== undefined && value !== null) {
|
|
@@ -325,7 +328,7 @@ export class APIClient {
|
|
|
325
328
|
private shouldRetry(error: AxiosError): boolean {
|
|
326
329
|
const retryCount = (error.config as any)?._retryCount || 0
|
|
327
330
|
const maxRetries = this.config.retries || ${this.options.retries}
|
|
328
|
-
|
|
331
|
+
|
|
329
332
|
return (
|
|
330
333
|
retryCount < maxRetries &&
|
|
331
334
|
(!error.response || error.response.status >= 500) &&
|
|
@@ -335,11 +338,11 @@ export class APIClient {
|
|
|
335
338
|
|
|
336
339
|
private async retryRequest(config: any): Promise<any> {
|
|
337
340
|
config._retryCount = (config._retryCount || 0) + 1
|
|
338
|
-
|
|
341
|
+
|
|
339
342
|
// Exponential backoff
|
|
340
343
|
const delay = Math.pow(2, config._retryCount) * 1000
|
|
341
344
|
await new Promise(resolve => setTimeout(resolve, delay))
|
|
342
|
-
|
|
345
|
+
|
|
343
346
|
return this.client.request(config)
|
|
344
347
|
}`;
|
|
345
348
|
}
|
|
@@ -500,7 +503,7 @@ export class APIClient {
|
|
|
500
503
|
const authTypes = this.generateAuthTypes();
|
|
501
504
|
return `/**
|
|
502
505
|
* API Client Types
|
|
503
|
-
*
|
|
506
|
+
*
|
|
504
507
|
* Configuration and utility types for the generated API client
|
|
505
508
|
*/
|
|
506
509
|
|
|
@@ -556,7 +559,7 @@ export interface APIError {
|
|
|
556
559
|
generateConfiguration() {
|
|
557
560
|
return `/**
|
|
558
561
|
* API Client Configuration
|
|
559
|
-
*
|
|
562
|
+
*
|
|
560
563
|
* Default configuration and factory functions
|
|
561
564
|
*/
|
|
562
565
|
|
|
@@ -580,7 +583,7 @@ export const defaultConfig: Partial<APIClientConfig> = {
|
|
|
580
583
|
generateIndexFile() {
|
|
581
584
|
return `/**
|
|
582
585
|
* Generated API Client
|
|
583
|
-
*
|
|
586
|
+
*
|
|
584
587
|
* Auto-generated from OpenAPI specification
|
|
585
588
|
*/
|
|
586
589
|
|
|
@@ -593,20 +596,85 @@ export { createAPIClient, defaultConfig } from './config'`;
|
|
|
593
596
|
generateFileHeader(title) {
|
|
594
597
|
return `/**
|
|
595
598
|
* ${title}
|
|
596
|
-
*
|
|
599
|
+
*
|
|
597
600
|
* Auto-generated from OpenAPI specification
|
|
598
601
|
* Do not edit manually
|
|
599
602
|
*/`;
|
|
600
603
|
}
|
|
601
604
|
getMethodName(endpoint) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
605
|
+
// Use semantic naming instead of raw operationId
|
|
606
|
+
return this.generateSemanticMethodName(endpoint);
|
|
607
|
+
}
|
|
608
|
+
generateSemanticMethodName(endpoint) {
|
|
609
|
+
// Extract resource from path
|
|
610
|
+
const pathSegments = endpoint.path.split('/').filter(s => s && !s.startsWith('{') && !['api', 'v1', 'v2'].includes(s));
|
|
611
|
+
const urlSegments = endpoint.path.split('/');
|
|
612
|
+
const hasPathParam = endpoint.path.includes('{');
|
|
613
|
+
const lastSegment = pathSegments[pathSegments.length - 1]?.replace(/-/g, '_') || 'resource';
|
|
614
|
+
const prevSegment = pathSegments[pathSegments.length - 2]?.replace(/-/g, '_');
|
|
615
|
+
|
|
616
|
+
// Auth endpoints - use action name directly
|
|
617
|
+
if (AUTH_ACTIONS.includes(lastSegment.toLowerCase())) {
|
|
618
|
+
return this.camelCase(lastSegment);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Health/status singletons
|
|
622
|
+
if (HEALTH_ENDPOINTS.includes(lastSegment.toLowerCase())) {
|
|
623
|
+
return this.camelCase(lastSegment);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// User profile singletons
|
|
627
|
+
if (USER_PROFILE_ENDPOINTS.includes(lastSegment.toLowerCase())) {
|
|
628
|
+
return this.camelCase(`get_current_user`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Metadata endpoints - include parent resource to avoid collision
|
|
632
|
+
if (lastSegment.toLowerCase() === 'metadata' && prevSegment) {
|
|
633
|
+
return hasPathParam
|
|
634
|
+
? this.camelCase(`get_${singularize(prevSegment)}_metadata`)
|
|
635
|
+
: this.camelCase(`get_${prevSegment}_metadata`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Collection actions (e.g., /activities/search)
|
|
639
|
+
if (COLLECTION_ACTIONS.includes(lastSegment.toLowerCase()) && prevSegment) {
|
|
640
|
+
return this.camelCase(`${lastSegment}_${prevSegment}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Determine resource name - for nested paths, include context
|
|
644
|
+
let resource = lastSegment;
|
|
645
|
+
if (pathSegments.length > 2 && hasPathParam) {
|
|
646
|
+
// e.g., /accounts/{id}/stages/allowed → get_account_allowed_stages
|
|
647
|
+
const parentResource = pathSegments.find((s, i) => {
|
|
648
|
+
const nextSeg = urlSegments[urlSegments.findIndex(u => u === s) + 1];
|
|
649
|
+
return nextSeg?.startsWith('{');
|
|
650
|
+
});
|
|
651
|
+
if (parentResource && parentResource !== lastSegment) {
|
|
652
|
+
resource = `${singularize(parentResource)}_${lastSegment}`;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Generate name based on HTTP method
|
|
657
|
+
switch (endpoint.method) {
|
|
658
|
+
case 'get':
|
|
659
|
+
return hasPathParam
|
|
660
|
+
? this.camelCase(`get_${singularize(resource)}`)
|
|
661
|
+
: this.camelCase(`list_${resource}`);
|
|
662
|
+
case 'post':
|
|
663
|
+
// Check for custom actions like /accounts/{id}/stage
|
|
664
|
+
const lastUrl = urlSegments[urlSegments.length - 1];
|
|
665
|
+
const prevUrl = urlSegments[urlSegments.length - 2];
|
|
666
|
+
if (!lastUrl.startsWith('{') && prevUrl?.startsWith('{')) {
|
|
667
|
+
return this.camelCase(`${lastUrl}_${singularize(prevSegment || resource)}`);
|
|
668
|
+
}
|
|
669
|
+
return this.camelCase(`create_${singularize(resource)}`);
|
|
670
|
+
case 'put':
|
|
671
|
+
case 'patch':
|
|
672
|
+
return this.camelCase(`update_${singularize(resource)}`);
|
|
673
|
+
case 'delete':
|
|
674
|
+
return this.camelCase(`delete_${singularize(resource)}`);
|
|
675
|
+
default:
|
|
676
|
+
return this.camelCase(`${endpoint.method}_${resource}`);
|
|
677
|
+
}
|
|
610
678
|
}
|
|
611
679
|
generatePathWithParams(endpoint) {
|
|
612
680
|
return endpoint.path.replace(/{([^}]+)}/g, '${$1}');
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confidence Scorer (Stub)
|
|
3
|
+
*
|
|
4
|
+
* Scores generated hook names for quality/confidence.
|
|
5
|
+
* This is a minimal stub that returns high confidence for all names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class ConfidenceScorer {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.scoringRules = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Score a generated hook name
|
|
15
|
+
* @returns {{ hookName: string, operationId: string, confidence: string, score: number, suggestions: string[] }}
|
|
16
|
+
*/
|
|
17
|
+
scoreHookName(hookName, operationId, path, method) {
|
|
18
|
+
// Simple heuristic: shorter names are better
|
|
19
|
+
const lengthScore = Math.max(0, 100 - hookName.length);
|
|
20
|
+
|
|
21
|
+
// Check for common anti-patterns
|
|
22
|
+
const hasApiV1 = hookName.toLowerCase().includes('apiv1');
|
|
23
|
+
const hasUnderscore = hookName.includes('_');
|
|
24
|
+
const isVeryLong = hookName.length > 40;
|
|
25
|
+
|
|
26
|
+
let score = lengthScore;
|
|
27
|
+
if (hasApiV1) score -= 30;
|
|
28
|
+
if (hasUnderscore) score -= 10;
|
|
29
|
+
if (isVeryLong) score -= 20;
|
|
30
|
+
|
|
31
|
+
// Normalize to 0-100
|
|
32
|
+
score = Math.max(0, Math.min(100, score));
|
|
33
|
+
|
|
34
|
+
// Determine confidence level
|
|
35
|
+
let confidence;
|
|
36
|
+
if (score >= 70) confidence = 'high';
|
|
37
|
+
else if (score >= 40) confidence = 'medium';
|
|
38
|
+
else confidence = 'low';
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
hookName,
|
|
42
|
+
operationId,
|
|
43
|
+
path,
|
|
44
|
+
method,
|
|
45
|
+
confidence,
|
|
46
|
+
score,
|
|
47
|
+
suggestions: confidence === 'low' ? [this.suggestBetterName(path, method)] : []
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
suggestBetterName(path, method) {
|
|
52
|
+
// Extract resource from path
|
|
53
|
+
const segments = path.split('/').filter(s => s && !s.startsWith('{') && !['api', 'v1'].includes(s));
|
|
54
|
+
const resource = segments[segments.length - 1]?.replace(/-/g, '') || 'resource';
|
|
55
|
+
|
|
56
|
+
// Capitalize first letter
|
|
57
|
+
const capitalizedResource = resource.charAt(0).toUpperCase() + resource.slice(1);
|
|
58
|
+
|
|
59
|
+
switch (method) {
|
|
60
|
+
case 'get':
|
|
61
|
+
return path.includes('{') ? `useGet${capitalizedResource}` : `use${capitalizedResource}`;
|
|
62
|
+
case 'post':
|
|
63
|
+
return `useCreate${capitalizedResource}`;
|
|
64
|
+
case 'put':
|
|
65
|
+
case 'patch':
|
|
66
|
+
return `useUpdate${capitalizedResource}`;
|
|
67
|
+
case 'delete':
|
|
68
|
+
return `useDelete${capitalizedResource}`;
|
|
69
|
+
default:
|
|
70
|
+
return `use${capitalizedResource}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
generateReport(scoredNames) {
|
|
75
|
+
const high = scoredNames.filter(s => s.confidence === 'high').length;
|
|
76
|
+
const medium = scoredNames.filter(s => s.confidence === 'medium').length;
|
|
77
|
+
const low = scoredNames.filter(s => s.confidence === 'low').length;
|
|
78
|
+
|
|
79
|
+
return `
|
|
80
|
+
Hook Name Quality Report
|
|
81
|
+
========================
|
|
82
|
+
Total hooks: ${scoredNames.length}
|
|
83
|
+
High confidence: ${high} (${Math.round(high/scoredNames.length*100)}%)
|
|
84
|
+
Medium confidence: ${medium} (${Math.round(medium/scoredNames.length*100)}%)
|
|
85
|
+
Low confidence: ${low} (${Math.round(low/scoredNames.length*100)}%)
|
|
86
|
+
|
|
87
|
+
${low > 0 ? 'Low confidence hooks that may need review:\n' +
|
|
88
|
+
scoredNames.filter(s => s.confidence === 'low')
|
|
89
|
+
.map(s => ` - ${s.hookName} → suggested: ${s.suggestions[0]}`)
|
|
90
|
+
.join('\n') : 'All hooks have acceptable names!'}
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook Configuration Manager (Stub)
|
|
3
|
+
*
|
|
4
|
+
* Provides name overrides and pattern configuration for hook generation.
|
|
5
|
+
* This is a minimal stub to unblock the hook generator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class HookConfigManager {
|
|
9
|
+
constructor(configPath) {
|
|
10
|
+
this.configPath = configPath;
|
|
11
|
+
this.overrides = {};
|
|
12
|
+
this.patterns = {
|
|
13
|
+
deduplicateSegments: true
|
|
14
|
+
};
|
|
15
|
+
this.reviewedNames = [];
|
|
16
|
+
this.statistics = {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async load() {
|
|
20
|
+
// No-op for stub - would load from config file
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async save() {
|
|
25
|
+
// No-op for stub - would save to config file
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getOverride(operationId) {
|
|
30
|
+
return this.overrides[operationId] || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getPatterns() {
|
|
34
|
+
return this.patterns;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getReviewedNames() {
|
|
38
|
+
return this.reviewedNames;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
updateStatistics(stats) {
|
|
42
|
+
this.statistics = { ...this.statistics, ...stats };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setOverride(operationId, hookName) {
|
|
46
|
+
this.overrides[operationId] = hookName;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Part of FRO-3: React Hook Generator
|
|
8
8
|
*/
|
|
9
|
-
import { HookConfigManager } from './hook-config';
|
|
10
|
-
import { ConfidenceScorer } from './confidence-scorer';
|
|
9
|
+
import { HookConfigManager } from './hook-config.js';
|
|
10
|
+
import { ConfidenceScorer } from './confidence-scorer.js';
|
|
11
|
+
import { singularize, pluralize, isPlural } from './naming-utils.js';
|
|
12
|
+
import { AUTH_ACTIONS, HEALTH_ENDPOINTS, USER_PROFILE_ENDPOINTS, SINGLETON_RESOURCES, COLLECTION_ACTIONS, SPECIAL_ACTIONS } from './naming-constants.js';
|
|
11
13
|
export class ReactHookGenerator {
|
|
12
14
|
options;
|
|
13
15
|
configManager;
|
|
@@ -203,13 +205,13 @@ export class ReactHookGenerator {
|
|
|
203
205
|
return ` onMutate: async (newData) => {
|
|
204
206
|
// Cancel outgoing refetches
|
|
205
207
|
await queryClient.cancelQueries({ queryKey: queryKeys.all })
|
|
206
|
-
|
|
208
|
+
|
|
207
209
|
// Snapshot previous value
|
|
208
210
|
const previousData = queryClient.getQueryData(queryKeys.all)
|
|
209
|
-
|
|
211
|
+
|
|
210
212
|
// Optimistically update cache
|
|
211
213
|
queryClient.setQueryData(queryKeys.all, (old: any) => [...(old || []), newData])
|
|
212
|
-
|
|
214
|
+
|
|
213
215
|
return { previousData }
|
|
214
216
|
},
|
|
215
217
|
onError: (err, newData, context) => {
|
|
@@ -220,14 +222,14 @@ export class ReactHookGenerator {
|
|
|
220
222
|
case 'patch':
|
|
221
223
|
return ` onMutate: async (updatedData) => {
|
|
222
224
|
await queryClient.cancelQueries({ queryKey: queryKeys.all })
|
|
223
|
-
|
|
225
|
+
|
|
224
226
|
const previousData = queryClient.getQueryData(queryKeys.all)
|
|
225
|
-
|
|
227
|
+
|
|
226
228
|
// Update specific item in cache
|
|
227
|
-
queryClient.setQueryData(queryKeys.all, (old: any) =>
|
|
229
|
+
queryClient.setQueryData(queryKeys.all, (old: any) =>
|
|
228
230
|
old?.map((item: any) => item.id === updatedData.id ? { ...item, ...updatedData } : item)
|
|
229
231
|
)
|
|
230
|
-
|
|
232
|
+
|
|
231
233
|
return { previousData }
|
|
232
234
|
},
|
|
233
235
|
onError: (err, updatedData, context) => {
|
|
@@ -236,14 +238,14 @@ export class ReactHookGenerator {
|
|
|
236
238
|
case 'delete':
|
|
237
239
|
return ` onMutate: async (id) => {
|
|
238
240
|
await queryClient.cancelQueries({ queryKey: queryKeys.all })
|
|
239
|
-
|
|
241
|
+
|
|
240
242
|
const previousData = queryClient.getQueryData(queryKeys.all)
|
|
241
|
-
|
|
243
|
+
|
|
242
244
|
// Remove item from cache
|
|
243
|
-
queryClient.setQueryData(queryKeys.all, (old: any) =>
|
|
245
|
+
queryClient.setQueryData(queryKeys.all, (old: any) =>
|
|
244
246
|
old?.filter((item: any) => item.id !== id)
|
|
245
247
|
)
|
|
246
|
-
|
|
248
|
+
|
|
247
249
|
return { previousData }
|
|
248
250
|
},
|
|
249
251
|
onError: (err, id, context) => {
|
|
@@ -275,18 +277,36 @@ export class ReactHookGenerator {
|
|
|
275
277
|
// Group endpoints by resource/tag
|
|
276
278
|
const groupedEndpoints = this.groupEndpointsByResource(endpoints);
|
|
277
279
|
for (const [resource, resourceEndpoints] of Object.entries(groupedEndpoints)) {
|
|
280
|
+
// Sanitize resource name to be a valid JS identifier
|
|
281
|
+
const sanitizedResource = this.sanitizeIdentifier(resource);
|
|
282
|
+
console.log(`DEBUG: resource="${resource}" -> sanitizedResource="${sanitizedResource}"`);
|
|
278
283
|
keys.push(` // ${resource} keys`);
|
|
279
|
-
keys.push(` ${
|
|
284
|
+
keys.push(` ${sanitizedResource}: () => [...queryKeys.all, '${sanitizedResource}'] as const,`);
|
|
285
|
+
const usedKeys = new Set([sanitizedResource]); // Track used keys to avoid duplicates
|
|
280
286
|
for (const endpoint of resourceEndpoints) {
|
|
281
287
|
if (endpoint.method !== 'get')
|
|
282
288
|
continue;
|
|
283
289
|
const operationName = this.getOperationName(endpoint);
|
|
284
|
-
|
|
290
|
+
let keyName = this.camelCase(operationName.replace(/^get/, ''));
|
|
291
|
+
// Skip if keyName matches sanitizedResource (base key already handles this)
|
|
292
|
+
if (keyName === sanitizedResource) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// If keyName already used, add suffix to disambiguate
|
|
296
|
+
if (usedKeys.has(keyName)) {
|
|
297
|
+
const isList = !endpoint.path.includes('{');
|
|
298
|
+
keyName = isList ? `${keyName}List` : `${keyName}Detail`;
|
|
299
|
+
}
|
|
300
|
+
// Still duplicate after suffix? Skip to avoid compilation error
|
|
301
|
+
if (usedKeys.has(keyName)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
usedKeys.add(keyName);
|
|
285
305
|
if (this.hasRequiredParams(endpoint)) {
|
|
286
|
-
keys.push(` ${keyName}: (params:
|
|
306
|
+
keys.push(` ${keyName}: (params: Record<string, unknown>) => [...queryKeys.${sanitizedResource}(), '${keyName}', params] as const,`);
|
|
287
307
|
}
|
|
288
308
|
else {
|
|
289
|
-
keys.push(` ${keyName}: () => [...queryKeys.${
|
|
309
|
+
keys.push(` ${keyName}: () => [...queryKeys.${sanitizedResource}(), '${keyName}'] as const,`);
|
|
290
310
|
}
|
|
291
311
|
}
|
|
292
312
|
keys.push('');
|
|
@@ -336,7 +356,7 @@ export class ReactHookGenerator {
|
|
|
336
356
|
generateFileHeader(title) {
|
|
337
357
|
return `/**
|
|
338
358
|
* ${title}
|
|
339
|
-
*
|
|
359
|
+
*
|
|
340
360
|
* Auto-generated React hooks from OpenAPI specification
|
|
341
361
|
* Do not edit manually - regenerate using the hook generator
|
|
342
362
|
*/`;
|
|
@@ -422,6 +442,8 @@ export class ReactHookGenerator {
|
|
|
422
442
|
/^(?<action>\w+?)_api_v\d+_(?<path>.+?)_(?<method>get|post|put|patch|delete)$/i,
|
|
423
443
|
// Pattern: action_resource_path_method
|
|
424
444
|
/^(?<action>\w+?)_(?<resource>\w+?)_(?<path>.+?)_(?<method>get|post|put|patch|delete)$/i,
|
|
445
|
+
// Pattern: simple action_action_method (e.g., ready_ready_get, health_health_get)
|
|
446
|
+
/^(?<action>\w+?)_\1_(?<method>get|post|put|patch|delete)$/i,
|
|
425
447
|
// Pattern: simple action_resource
|
|
426
448
|
/^(?<action>\w+?)_(?<resource>\w+?)$/i,
|
|
427
449
|
];
|
|
@@ -439,7 +461,12 @@ export class ReactHookGenerator {
|
|
|
439
461
|
// Check if it's a custom action
|
|
440
462
|
const urlSegments = endpoint.path.split('/');
|
|
441
463
|
const lastSegment = urlSegments[urlSegments.length - 1];
|
|
442
|
-
const
|
|
464
|
+
const prevSegment = urlSegments[urlSegments.length - 2];
|
|
465
|
+
// Custom action: either after a path param (e.g., /accounts/{id}/archive)
|
|
466
|
+
// or a known action verb on a collection (e.g., /activities/search)
|
|
467
|
+
const isResourceAction = !lastSegment.startsWith('{') && prevSegment?.startsWith('{');
|
|
468
|
+
const isCollectionAction = !lastSegment.startsWith('{') && !prevSegment?.startsWith('{') && COLLECTION_ACTIONS.includes(lastSegment);
|
|
469
|
+
const isCustomAction = isResourceAction || isCollectionAction;
|
|
443
470
|
// Extract or infer the action verb
|
|
444
471
|
const actionVerb = this.extractActionVerb(components.action || operationId, endpoint.method);
|
|
445
472
|
// Extract or infer the resource
|
|
@@ -449,7 +476,7 @@ export class ReactHookGenerator {
|
|
|
449
476
|
'resource';
|
|
450
477
|
return {
|
|
451
478
|
action: actionVerb,
|
|
452
|
-
resource: resource,
|
|
479
|
+
resource: isCollectionAction ? prevSegment : resource,
|
|
453
480
|
path: components.path || '',
|
|
454
481
|
method: endpoint.method,
|
|
455
482
|
isCustomAction,
|
|
@@ -463,10 +490,10 @@ export class ReactHookGenerator {
|
|
|
463
490
|
extractActionVerb(actionPart, method) {
|
|
464
491
|
// Common action verbs to recognize
|
|
465
492
|
const actionVerbs = [
|
|
466
|
-
|
|
467
|
-
|
|
493
|
+
...AUTH_ACTIONS,
|
|
494
|
+
...HEALTH_ENDPOINTS,
|
|
468
495
|
'list', 'get', 'create', 'update', 'delete',
|
|
469
|
-
|
|
496
|
+
...COLLECTION_ACTIONS,
|
|
470
497
|
'approve', 'reject', 'archive', 'restore',
|
|
471
498
|
'sync', 'refresh', 'reset', 'verify', 'validate'
|
|
472
499
|
];
|
|
@@ -501,11 +528,7 @@ export class ReactHookGenerator {
|
|
|
501
528
|
* Check if this is a special endpoint that should keep its action verb
|
|
502
529
|
*/
|
|
503
530
|
isSpecialEndpoint(parsed) {
|
|
504
|
-
|
|
505
|
-
'login', 'logout', 'register', 'signup', 'signin',
|
|
506
|
-
'health', 'status', 'check', 'verify', 'validate'
|
|
507
|
-
];
|
|
508
|
-
return specialActions.includes(parsed.action);
|
|
531
|
+
return SPECIAL_ACTIONS.includes(parsed.action);
|
|
509
532
|
}
|
|
510
533
|
/**
|
|
511
534
|
* Format name for custom action endpoints
|
|
@@ -519,17 +542,18 @@ export class ReactHookGenerator {
|
|
|
519
542
|
return customActionName;
|
|
520
543
|
}
|
|
521
544
|
// Otherwise combine resource and action
|
|
522
|
-
return `${
|
|
545
|
+
return `${singularize(resource)}_${customActionName}`;
|
|
523
546
|
}
|
|
524
|
-
return `${action}_${
|
|
547
|
+
return `${action}_${singularize(resource)}`;
|
|
525
548
|
}
|
|
526
549
|
/**
|
|
527
550
|
* Format name for special endpoints (login, health, etc.)
|
|
528
551
|
*/
|
|
529
552
|
formatSpecialEndpointName(parsed) {
|
|
530
553
|
const { action, resource } = parsed;
|
|
531
|
-
// For
|
|
532
|
-
|
|
554
|
+
// For simple singleton endpoints, just return the action
|
|
555
|
+
const simpleActions = [...AUTH_ACTIONS.filter(a => ['login', 'logout'].includes(a)), ...HEALTH_ENDPOINTS.filter(h => ['health', 'ready', 'status'].includes(h)), ...USER_PROFILE_ENDPOINTS];
|
|
556
|
+
if (simpleActions.includes(action)) {
|
|
533
557
|
return action;
|
|
534
558
|
}
|
|
535
559
|
// For other special endpoints, combine with resource if meaningful
|
|
@@ -544,19 +568,19 @@ export class ReactHookGenerator {
|
|
|
544
568
|
formatStandardOperationName(parsed, endpoint) {
|
|
545
569
|
const { resource } = parsed;
|
|
546
570
|
// Determine if it's a list or single resource operation
|
|
547
|
-
const isList = endpoint.method === 'get' && !endpoint.path.includes('{');
|
|
571
|
+
const isList = endpoint.method === 'get' && !endpoint.path.includes('{') && !this.isSingletonEndpoint(resource, endpoint.path);
|
|
548
572
|
switch (endpoint.method) {
|
|
549
573
|
case 'get':
|
|
550
|
-
return isList ?
|
|
574
|
+
return isList ? pluralize(resource) : singularize(resource);
|
|
551
575
|
case 'post':
|
|
552
|
-
return `create_${
|
|
576
|
+
return `create_${singularize(resource)}`;
|
|
553
577
|
case 'put':
|
|
554
578
|
case 'patch':
|
|
555
|
-
return `update_${
|
|
579
|
+
return `update_${singularize(resource)}`;
|
|
556
580
|
case 'delete':
|
|
557
|
-
return `delete_${
|
|
581
|
+
return `delete_${singularize(resource)}`;
|
|
558
582
|
default:
|
|
559
|
-
return
|
|
583
|
+
return singularize(resource);
|
|
560
584
|
}
|
|
561
585
|
}
|
|
562
586
|
generateCleanName(endpoint) {
|
|
@@ -578,7 +602,7 @@ export class ReactHookGenerator {
|
|
|
578
602
|
const action = lastSegment.replace(/-/g, '_');
|
|
579
603
|
return `${mainResource}_${action}`;
|
|
580
604
|
}
|
|
581
|
-
// For nested resources (e.g., /categories/{id}/subcategories),
|
|
605
|
+
// For nested resources (e.g., /categories/{id}/subcategories),
|
|
582
606
|
// just use the last resource name
|
|
583
607
|
if (pathParts.length > 2 && segments.some(s => s.startsWith('{'))) {
|
|
584
608
|
// This is a nested resource, just use the last part
|
|
@@ -591,7 +615,7 @@ export class ReactHookGenerator {
|
|
|
591
615
|
const isList = !endpoint.path.includes('{');
|
|
592
616
|
// Only pluralize if it's a list and not already plural
|
|
593
617
|
if (isList && !cleanResource.endsWith('s') && !cleanResource.endsWith('ies')) {
|
|
594
|
-
return
|
|
618
|
+
return pluralize(cleanResource);
|
|
595
619
|
}
|
|
596
620
|
return cleanResource;
|
|
597
621
|
case 'post':
|
|
@@ -605,28 +629,22 @@ export class ReactHookGenerator {
|
|
|
605
629
|
return `${endpoint.method}_${cleanResource}`;
|
|
606
630
|
}
|
|
607
631
|
}
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
632
|
+
isSingletonEndpoint(resource, path) {
|
|
633
|
+
// Check if resource matches singleton pattern
|
|
634
|
+
const lowerResource = resource.toLowerCase().replace(/_/g, '');
|
|
635
|
+
if (SINGLETON_RESOURCES.some(s => lowerResource === s || lowerResource.endsWith(s))) {
|
|
636
|
+
return true;
|
|
612
637
|
}
|
|
613
|
-
|
|
614
|
-
|
|
638
|
+
|
|
639
|
+
// Check path patterns for common singletons
|
|
640
|
+
const pathSegments = path.split('/').filter(s => s && !s.startsWith('{'));
|
|
641
|
+
const lastSegment = pathSegments[pathSegments.length - 1]?.toLowerCase();
|
|
642
|
+
|
|
643
|
+
if (SINGLETON_RESOURCES.includes(lastSegment)) {
|
|
644
|
+
return true;
|
|
615
645
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
singularize(word) {
|
|
619
|
-
// Simple singularization rules
|
|
620
|
-
if (word.endsWith('ies')) {
|
|
621
|
-
return word.slice(0, -3) + 'y';
|
|
622
|
-
}
|
|
623
|
-
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('ches') || word.endsWith('shes')) {
|
|
624
|
-
return word.slice(0, -2);
|
|
625
|
-
}
|
|
626
|
-
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
627
|
-
return word.slice(0, -1);
|
|
628
|
-
}
|
|
629
|
-
return word;
|
|
646
|
+
|
|
647
|
+
return false;
|
|
630
648
|
}
|
|
631
649
|
hasRequiredParams(endpoint) {
|
|
632
650
|
return endpoint.parameters.some(p => p.required) ||
|
|
@@ -663,6 +681,15 @@ export class ReactHookGenerator {
|
|
|
663
681
|
}
|
|
664
682
|
}
|
|
665
683
|
isListEndpoint(endpoint) {
|
|
684
|
+
// Extract resource for singleton check
|
|
685
|
+
const pathSegments = endpoint.path.split('/').filter(s => s && !s.startsWith('{'));
|
|
686
|
+
const resource = pathSegments[pathSegments.length - 1]?.replace(/-/g, '_') || '';
|
|
687
|
+
|
|
688
|
+
// Singleton endpoints are never list endpoints
|
|
689
|
+
if (this.isSingletonEndpoint(resource, endpoint.path)) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
666
693
|
return endpoint.path.includes('list') ||
|
|
667
694
|
!endpoint.path.includes('{') ||
|
|
668
695
|
(endpoint.summary?.toLowerCase().includes('list') ?? false) ||
|
|
@@ -671,7 +698,9 @@ export class ReactHookGenerator {
|
|
|
671
698
|
groupEndpointsByResource(endpoints) {
|
|
672
699
|
const groups = {};
|
|
673
700
|
for (const endpoint of endpoints) {
|
|
674
|
-
const
|
|
701
|
+
const tag = endpoint.tags?.[0] || 'default';
|
|
702
|
+
// Sanitize tag to be a valid JS identifier (camelCase, no spaces)
|
|
703
|
+
const resource = this.sanitizeIdentifier(tag);
|
|
675
704
|
if (!groups[resource]) {
|
|
676
705
|
groups[resource] = [];
|
|
677
706
|
}
|
|
@@ -679,8 +708,17 @@ export class ReactHookGenerator {
|
|
|
679
708
|
}
|
|
680
709
|
return groups;
|
|
681
710
|
}
|
|
711
|
+
sanitizeIdentifier(str) {
|
|
712
|
+
// Convert to camelCase and remove invalid characters
|
|
713
|
+
return str
|
|
714
|
+
.replace(/[^a-zA-Z0-9\s]/g, '') // Remove special chars except spaces
|
|
715
|
+
.split(/\s+/) // Split on whitespace
|
|
716
|
+
.map((word, i) => i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
717
|
+
.join('');
|
|
718
|
+
}
|
|
682
719
|
getRelatedQueryTags(endpoint) {
|
|
683
|
-
const
|
|
720
|
+
const tag = endpoint.tags?.[0] || 'default';
|
|
721
|
+
const resource = this.sanitizeIdentifier(tag);
|
|
684
722
|
return [resource, 'all'];
|
|
685
723
|
}
|
|
686
724
|
camelCase(str) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming Constants
|
|
3
|
+
*
|
|
4
|
+
* Shared constants for magic strings used across hook and client generators.
|
|
5
|
+
* These constants help identify special endpoints that get unique naming treatment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Authentication action endpoints
|
|
10
|
+
* These endpoints are named directly without CRUD prefixes (e.g., 'login' not 'createLogin')
|
|
11
|
+
*/
|
|
12
|
+
export const AUTH_ACTIONS = [
|
|
13
|
+
'login',
|
|
14
|
+
'logout',
|
|
15
|
+
'register',
|
|
16
|
+
'signup',
|
|
17
|
+
'signin',
|
|
18
|
+
'refresh'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Health/status check endpoints
|
|
23
|
+
* These are singleton endpoints that return system status
|
|
24
|
+
*/
|
|
25
|
+
export const HEALTH_ENDPOINTS = [
|
|
26
|
+
'health',
|
|
27
|
+
'ready',
|
|
28
|
+
'status',
|
|
29
|
+
'info',
|
|
30
|
+
'version'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* User profile endpoints
|
|
35
|
+
* These endpoints reference the current/authenticated user
|
|
36
|
+
*/
|
|
37
|
+
export const USER_PROFILE_ENDPOINTS = [
|
|
38
|
+
'me',
|
|
39
|
+
'self',
|
|
40
|
+
'profile',
|
|
41
|
+
'current'
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Singleton resources
|
|
46
|
+
* Resources that return a single item, not a collection
|
|
47
|
+
* Includes all auth, health, and user profile endpoints plus common singleton patterns
|
|
48
|
+
*/
|
|
49
|
+
export const SINGLETON_RESOURCES = [
|
|
50
|
+
'me',
|
|
51
|
+
'self',
|
|
52
|
+
'current',
|
|
53
|
+
'profile',
|
|
54
|
+
'health',
|
|
55
|
+
'ready',
|
|
56
|
+
'status',
|
|
57
|
+
'info',
|
|
58
|
+
'version',
|
|
59
|
+
'metadata',
|
|
60
|
+
'config',
|
|
61
|
+
'settings',
|
|
62
|
+
'preferences',
|
|
63
|
+
'summary',
|
|
64
|
+
'stats',
|
|
65
|
+
'statistics',
|
|
66
|
+
'dashboard',
|
|
67
|
+
'schema',
|
|
68
|
+
'spec',
|
|
69
|
+
'openapi'
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Collection action verbs
|
|
74
|
+
* Actions that operate on collections (e.g., /activities/search)
|
|
75
|
+
*/
|
|
76
|
+
export const COLLECTION_ACTIONS = [
|
|
77
|
+
'search',
|
|
78
|
+
'export',
|
|
79
|
+
'import',
|
|
80
|
+
'bulk',
|
|
81
|
+
'batch',
|
|
82
|
+
'count',
|
|
83
|
+
'stats',
|
|
84
|
+
'validate'
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Special actions - combination of auth, health, and user profile endpoints
|
|
89
|
+
* These endpoints get special naming treatment and keep their action verb
|
|
90
|
+
*/
|
|
91
|
+
export const SPECIAL_ACTIONS = [
|
|
92
|
+
...AUTH_ACTIONS,
|
|
93
|
+
...HEALTH_ENDPOINTS,
|
|
94
|
+
...USER_PROFILE_ENDPOINTS,
|
|
95
|
+
'check',
|
|
96
|
+
'verify',
|
|
97
|
+
'validate'
|
|
98
|
+
];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared naming utilities for OpenAPI code generation
|
|
3
|
+
* Handles singular/plural conversions with proper edge case handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Map of irregular plural forms
|
|
8
|
+
*/
|
|
9
|
+
export const IRREGULAR_PLURALS = {
|
|
10
|
+
person: 'people',
|
|
11
|
+
child: 'children',
|
|
12
|
+
data: 'data', // already plural
|
|
13
|
+
media: 'media', // already plural
|
|
14
|
+
metadata: 'metadata', // already plural
|
|
15
|
+
people: 'people', // already plural
|
|
16
|
+
children: 'children', // already plural
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Map of irregular singular forms (reverse lookup)
|
|
21
|
+
*/
|
|
22
|
+
const IRREGULAR_SINGULARS = {
|
|
23
|
+
people: 'person',
|
|
24
|
+
children: 'child',
|
|
25
|
+
data: 'data', // already singular
|
|
26
|
+
media: 'media', // already singular
|
|
27
|
+
metadata: 'metadata', // already singular
|
|
28
|
+
person: 'person', // already singular
|
|
29
|
+
child: 'child', // already singular
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Words that naturally end in 's' but are singular
|
|
34
|
+
*/
|
|
35
|
+
const SINGULAR_EXCEPTIONS = [
|
|
36
|
+
'status',
|
|
37
|
+
'class',
|
|
38
|
+
'address',
|
|
39
|
+
'access',
|
|
40
|
+
'success',
|
|
41
|
+
'process',
|
|
42
|
+
'bus',
|
|
43
|
+
'focus',
|
|
44
|
+
'virus',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a word is already plural
|
|
49
|
+
* @param {string} word - The word to check
|
|
50
|
+
* @returns {boolean} - True if the word is plural
|
|
51
|
+
*/
|
|
52
|
+
export function isPlural(word) {
|
|
53
|
+
// Check irregular plurals first
|
|
54
|
+
if (IRREGULAR_PLURALS[word.toLowerCase()]) {
|
|
55
|
+
return IRREGULAR_PLURALS[word.toLowerCase()] === word.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check singular exceptions - these end in 's' but are not plural
|
|
59
|
+
if (SINGULAR_EXCEPTIONS.includes(word.toLowerCase())) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check common plural endings
|
|
64
|
+
if (word.endsWith('ies') || word.endsWith('es') || word.endsWith('s')) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert plural word to singular
|
|
73
|
+
* @param {string} word - The word to singularize
|
|
74
|
+
* @returns {string} - The singular form of the word
|
|
75
|
+
*/
|
|
76
|
+
export function singularize(word) {
|
|
77
|
+
// Handle empty or short words
|
|
78
|
+
if (!word || word.length <= 1) {
|
|
79
|
+
return word;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check irregular singulars first
|
|
83
|
+
const lowerWord = word.toLowerCase();
|
|
84
|
+
if (IRREGULAR_SINGULARS[lowerWord]) {
|
|
85
|
+
return IRREGULAR_SINGULARS[lowerWord];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Already singular (check exceptions)
|
|
89
|
+
if (SINGULAR_EXCEPTIONS.includes(lowerWord)) {
|
|
90
|
+
return word;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -ies → -y (activities → activity)
|
|
94
|
+
if (word.endsWith('ies')) {
|
|
95
|
+
return word.slice(0, -3) + 'y';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -ses → -s (processes → process, but 'process' is already singular)
|
|
99
|
+
// -xes → -x (boxes → box)
|
|
100
|
+
// -ches → -ch (watches → watch)
|
|
101
|
+
// -shes → -sh (dishes → dish)
|
|
102
|
+
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('ches') || word.endsWith('shes')) {
|
|
103
|
+
return word.slice(0, -2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Regular -s ending (cats → cat)
|
|
107
|
+
// But avoid words ending in -ss (class, success, etc.)
|
|
108
|
+
if (word.endsWith('s') && !word.endsWith('ss') && word.length > 1) {
|
|
109
|
+
return word.slice(0, -1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return word;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert singular word to plural
|
|
117
|
+
* @param {string} word - The word to pluralize
|
|
118
|
+
* @returns {string} - The plural form of the word
|
|
119
|
+
*/
|
|
120
|
+
export function pluralize(word) {
|
|
121
|
+
// Handle empty or short words
|
|
122
|
+
if (!word || word.length <= 1) {
|
|
123
|
+
return word;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if already plural
|
|
127
|
+
if (isPlural(word)) {
|
|
128
|
+
return word;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check irregular plurals first
|
|
132
|
+
const lowerWord = word.toLowerCase();
|
|
133
|
+
if (IRREGULAR_PLURALS[lowerWord]) {
|
|
134
|
+
return IRREGULAR_PLURALS[lowerWord];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// -y ending (not vowel+y) → -ies (activity → activities)
|
|
138
|
+
if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) {
|
|
139
|
+
return word.slice(0, -1) + 'ies';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -x, -ch, -sh endings → add 'es' (box → boxes, watch → watches, dish → dishes)
|
|
143
|
+
if (word.endsWith('x') || word.endsWith('ch') || word.endsWith('sh')) {
|
|
144
|
+
return word + 'es';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Regular: add 's' (cat → cats, dog → dogs)
|
|
148
|
+
return word + 's';
|
|
149
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@pattern-stack/frontend-patterns",
|
|
3
3
|
"description": "Production-ready React frontend template with atomic architecture patterns. Build ultra-lean applications by importing shared UI foundation patterns.",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "0.2.0-alpha.
|
|
5
|
+
"version": "0.2.0-alpha.1",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"react",
|
|
8
8
|
"typescript",
|