@pikku/openapi-parser 0.12.9 → 0.12.10
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/CHANGELOG.md +15 -0
- package/dist/codegen.d.ts +1 -0
- package/dist/codegen.js +586 -66
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/naming.d.ts +7 -0
- package/dist/naming.js +136 -11
- package/dist/openapi-to-zod-schema.d.ts +20 -7
- package/dist/openapi-to-zod-schema.js +245 -33
- package/dist/parse-openapi.js +22 -9
- package/dist/zod-codegen.d.ts +1 -1
- package/dist/zod-codegen.js +1 -1
- package/package.json +1 -1
- package/src/codegen.test.ts +782 -0
- package/src/codegen.ts +726 -74
- package/src/index.ts +2 -3
- package/src/naming.test.ts +260 -0
- package/src/naming.ts +155 -14
- package/src/openapi-to-zod-schema.test.ts +664 -0
- package/src/openapi-to-zod-schema.ts +270 -35
- package/src/parse-openapi.ts +22 -9
- package/src/zod-codegen.ts +1 -0
package/src/index.ts
CHANGED
|
@@ -8,9 +8,7 @@ export {
|
|
|
8
8
|
type SecuritySchemeInfo,
|
|
9
9
|
} from './parse-openapi.js'
|
|
10
10
|
|
|
11
|
-
export {
|
|
12
|
-
generateAddonFromOpenAPI,
|
|
13
|
-
} from './codegen.js'
|
|
11
|
+
export { generateAddonFromOpenAPI } from './codegen.js'
|
|
14
12
|
|
|
15
13
|
export {
|
|
16
14
|
generateOperationNames,
|
|
@@ -25,4 +23,5 @@ export {
|
|
|
25
23
|
schemaToZod,
|
|
26
24
|
schemaVarName,
|
|
27
25
|
sanitizeTypeName,
|
|
26
|
+
snakeToCamel,
|
|
28
27
|
} from './openapi-to-zod-schema.js'
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import {
|
|
4
|
+
sanitizeIdentifier,
|
|
5
|
+
singularize,
|
|
6
|
+
toCamelCase,
|
|
7
|
+
toPascalCase,
|
|
8
|
+
detectCommonPrefix,
|
|
9
|
+
generateOperationNames,
|
|
10
|
+
} from './naming.js'
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// sanitizeIdentifier
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
describe('sanitizeIdentifier', () => {
|
|
16
|
+
test('prefixes reserved words with underscore', () => {
|
|
17
|
+
assert.equal(sanitizeIdentifier('delete'), '_delete')
|
|
18
|
+
assert.equal(sanitizeIdentifier('package'), '_package')
|
|
19
|
+
assert.equal(sanitizeIdentifier('class'), '_class')
|
|
20
|
+
assert.equal(sanitizeIdentifier('import'), '_import')
|
|
21
|
+
assert.equal(sanitizeIdentifier('export'), '_export')
|
|
22
|
+
assert.equal(sanitizeIdentifier('return'), '_return')
|
|
23
|
+
assert.equal(sanitizeIdentifier('function'), '_function')
|
|
24
|
+
assert.equal(sanitizeIdentifier('yield'), '_yield')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('prefixes names starting with digits with "n"', () => {
|
|
28
|
+
assert.equal(
|
|
29
|
+
sanitizeIdentifier('4oDBrowseByDateFeed'),
|
|
30
|
+
'n4oDBrowseByDateFeed'
|
|
31
|
+
)
|
|
32
|
+
assert.equal(sanitizeIdentifier('123abc'), 'n123abc')
|
|
33
|
+
assert.equal(sanitizeIdentifier('0'), 'n0')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('strips numeric separators (1_000 → 1000)', () => {
|
|
37
|
+
assert.equal(sanitizeIdentifier('limit1_000'), 'limit1000')
|
|
38
|
+
assert.equal(sanitizeIdentifier('max1_000_000'), 'max1000000')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('strips curly braces from operationId-like names', () => {
|
|
42
|
+
// sanitizeIdentifier strips all non-alphanumeric/underscore/$
|
|
43
|
+
assert.equal(
|
|
44
|
+
sanitizeIdentifier('createProfiles{extension}'),
|
|
45
|
+
'createProfilesextension'
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('strips dashes and special characters', () => {
|
|
50
|
+
assert.equal(sanitizeIdentifier('foo-bar'), 'foobar')
|
|
51
|
+
assert.equal(sanitizeIdentifier('hello.world'), 'helloworld')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('returns "unnamed" for empty string after sanitization', () => {
|
|
55
|
+
assert.equal(sanitizeIdentifier('---'), 'unnamed')
|
|
56
|
+
assert.equal(sanitizeIdentifier(''), 'unnamed')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('does not prefix underscore-only names as digit-leading', () => {
|
|
60
|
+
assert.equal(sanitizeIdentifier('_foo'), '_foo')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// singularize
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
describe('singularize', () => {
|
|
68
|
+
test('regular plurals', () => {
|
|
69
|
+
assert.equal(singularize('users'), 'user')
|
|
70
|
+
assert.equal(singularize('products'), 'product')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('ies → y', () => {
|
|
74
|
+
assert.equal(singularize('categories'), 'category')
|
|
75
|
+
assert.equal(singularize('entries'), 'entry')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('irregular plurals', () => {
|
|
79
|
+
assert.equal(singularize('addresses'), 'address')
|
|
80
|
+
assert.equal(singularize('statuses'), 'status')
|
|
81
|
+
assert.equal(singularize('indices'), 'index')
|
|
82
|
+
assert.equal(singularize('aliases'), 'alias')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('shes/ches/xes/zes/sses → drop "es"', () => {
|
|
86
|
+
assert.equal(singularize('bushes'), 'bush')
|
|
87
|
+
assert.equal(singularize('churches'), 'church')
|
|
88
|
+
assert.equal(singularize('boxes'), 'box')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('preserves words ending in ss or us', () => {
|
|
92
|
+
assert.equal(singularize('class'), 'class')
|
|
93
|
+
assert.equal(singularize('campus'), 'campus')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('does not singularize short words', () => {
|
|
97
|
+
assert.equal(singularize('as'), 'as')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// toCamelCase / toPascalCase
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
describe('toCamelCase', () => {
|
|
105
|
+
test('converts kebab-case', () => {
|
|
106
|
+
assert.equal(toCamelCase('foo-bar'), 'fooBar')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('converts snake_case', () => {
|
|
110
|
+
assert.equal(toCamelCase('foo_bar'), 'fooBar')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('lowers leading capital', () => {
|
|
114
|
+
assert.equal(toCamelCase('FooBar'), 'fooBar')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('strips trailing non-alphanumeric', () => {
|
|
118
|
+
assert.equal(toCamelCase('foo-bar-'), 'fooBar')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('toPascalCase', () => {
|
|
123
|
+
test('uppercases first letter', () => {
|
|
124
|
+
assert.equal(toPascalCase('foo-bar'), 'FooBar')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// detectCommonPrefix
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
describe('detectCommonPrefix', () => {
|
|
132
|
+
test('detects /api/v1/ prefix shared by majority', () => {
|
|
133
|
+
const paths = [
|
|
134
|
+
'/api/v1/users',
|
|
135
|
+
'/api/v1/users/{id}',
|
|
136
|
+
'/api/v1/products',
|
|
137
|
+
'/api/v1/orders',
|
|
138
|
+
]
|
|
139
|
+
assert.equal(detectCommonPrefix(paths), '/api/v1/')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('detects prefix even with outliers below 25%', () => {
|
|
143
|
+
const paths = [
|
|
144
|
+
'/api/v2/users',
|
|
145
|
+
'/api/v2/products',
|
|
146
|
+
'/api/v2/orders',
|
|
147
|
+
'/api/v2/settings',
|
|
148
|
+
'/oauth/authorize', // outlier
|
|
149
|
+
]
|
|
150
|
+
// 4 out of 5 share /api/v2/ → 80% > 75% threshold
|
|
151
|
+
assert.equal(detectCommonPrefix(paths), '/api/v2/')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('returns empty when no common prefix', () => {
|
|
155
|
+
const paths = ['/users', '/products', '/orders']
|
|
156
|
+
assert.equal(detectCommonPrefix(paths), '')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('returns empty for empty array', () => {
|
|
160
|
+
assert.equal(detectCommonPrefix([]), '')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('skips path-parameter segments as prefix candidates', () => {
|
|
164
|
+
const paths = ['/{version}/users', '/{version}/products']
|
|
165
|
+
// {version} starts with {, so it should not be considered a prefix
|
|
166
|
+
assert.equal(detectCommonPrefix(paths), '')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// generateOperationNames
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
describe('generateOperationNames', () => {
|
|
174
|
+
test('uses operationId when available', () => {
|
|
175
|
+
const ops = [{ method: 'get', path: '/users', operationId: 'listUsers' }]
|
|
176
|
+
const result = generateOperationNames(ops, '')
|
|
177
|
+
assert.equal(result[0].functionName, 'listUsers')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('strips curly braces from operationId', () => {
|
|
181
|
+
const ops = [
|
|
182
|
+
{
|
|
183
|
+
method: 'post',
|
|
184
|
+
path: '/profiles',
|
|
185
|
+
operationId: 'createProfiles{extension}',
|
|
186
|
+
},
|
|
187
|
+
]
|
|
188
|
+
const result = generateOperationNames(ops, '')
|
|
189
|
+
// {} are stripped, then toCamelCase runs — no separator between "Profiles" and "extension"
|
|
190
|
+
// so the result is "createProfilesextension"
|
|
191
|
+
assert.equal(result[0].functionName, 'createProfilesextension')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('strips curly braces with separators from operationId', () => {
|
|
195
|
+
const ops = [
|
|
196
|
+
{
|
|
197
|
+
method: 'post',
|
|
198
|
+
path: '/profiles',
|
|
199
|
+
operationId: 'create-profiles-{extension}',
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
const result = generateOperationNames(ops, '')
|
|
203
|
+
// With dashes as separators, toCamelCase capitalizes after each separator
|
|
204
|
+
assert.equal(result[0].functionName, 'createProfilesExtension')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('derives name from path when no operationId', () => {
|
|
208
|
+
const ops = [{ method: 'get', path: '/users/{id}' }]
|
|
209
|
+
const result = generateOperationNames(ops, '')
|
|
210
|
+
assert.equal(result[0].functionName, 'getUser')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('uses "list" prefix for GET on collection', () => {
|
|
214
|
+
const ops = [{ method: 'get', path: '/users' }]
|
|
215
|
+
const result = generateOperationNames(ops, '')
|
|
216
|
+
assert.equal(result[0].functionName, 'listUsers')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('handles collision by appending counter', () => {
|
|
220
|
+
const ops = [
|
|
221
|
+
{ method: 'get', path: '/users', operationId: 'listUsers' },
|
|
222
|
+
{ method: 'get', path: '/v2/users', operationId: 'listUsers' },
|
|
223
|
+
]
|
|
224
|
+
const result = generateOperationNames(ops, '')
|
|
225
|
+
assert.equal(result[0].functionName, 'listUsers')
|
|
226
|
+
assert.equal(result[1].functionName, 'listUsers2')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('sanitizes reserved word operationIds', () => {
|
|
230
|
+
const ops = [
|
|
231
|
+
{ method: 'delete', path: '/items/{id}', operationId: 'delete' },
|
|
232
|
+
]
|
|
233
|
+
const result = generateOperationNames(ops, '')
|
|
234
|
+
assert.equal(result[0].functionName, '_delete')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('sanitizes digit-leading operationIds', () => {
|
|
238
|
+
const ops = [
|
|
239
|
+
{ method: 'get', path: '/feeds', operationId: '4oDBrowseByDateFeed' },
|
|
240
|
+
]
|
|
241
|
+
const result = generateOperationNames(ops, '')
|
|
242
|
+
assert.equal(result[0].functionName, 'n4oDBrowseByDateFeed')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('strips common prefix from path-derived names', () => {
|
|
246
|
+
const ops = [
|
|
247
|
+
{ method: 'get', path: '/api/v1/users' },
|
|
248
|
+
{ method: 'get', path: '/api/v1/products' },
|
|
249
|
+
]
|
|
250
|
+
const result = generateOperationNames(ops, '/api/v1/')
|
|
251
|
+
assert.equal(result[0].functionName, 'listUsers')
|
|
252
|
+
assert.equal(result[1].functionName, 'listProducts')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('handles dashes and underscores in operationIds', () => {
|
|
256
|
+
const ops = [{ method: 'get', path: '/x', operationId: 'get-all_items' }]
|
|
257
|
+
const result = generateOperationNames(ops, '')
|
|
258
|
+
assert.equal(result[0].functionName, 'getAllItems')
|
|
259
|
+
})
|
|
260
|
+
})
|
package/src/naming.ts
CHANGED
|
@@ -3,6 +3,118 @@
|
|
|
3
3
|
* Uses operationId when available, otherwise generates from path segments.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/** JavaScript/TypeScript reserved words that cannot be used as identifiers */
|
|
7
|
+
const JS_RESERVED_WORDS = new Set([
|
|
8
|
+
// ECMAScript reserved words
|
|
9
|
+
'abstract',
|
|
10
|
+
'arguments',
|
|
11
|
+
'await',
|
|
12
|
+
'boolean',
|
|
13
|
+
'break',
|
|
14
|
+
'byte',
|
|
15
|
+
'case',
|
|
16
|
+
'catch',
|
|
17
|
+
'char',
|
|
18
|
+
'class',
|
|
19
|
+
'const',
|
|
20
|
+
'continue',
|
|
21
|
+
'debugger',
|
|
22
|
+
'default',
|
|
23
|
+
'delete',
|
|
24
|
+
'do',
|
|
25
|
+
'double',
|
|
26
|
+
'else',
|
|
27
|
+
'enum',
|
|
28
|
+
'eval',
|
|
29
|
+
'export',
|
|
30
|
+
'extends',
|
|
31
|
+
'false',
|
|
32
|
+
'final',
|
|
33
|
+
'finally',
|
|
34
|
+
'float',
|
|
35
|
+
'for',
|
|
36
|
+
'function',
|
|
37
|
+
'goto',
|
|
38
|
+
'if',
|
|
39
|
+
'implements',
|
|
40
|
+
'import',
|
|
41
|
+
'in',
|
|
42
|
+
'instanceof',
|
|
43
|
+
'int',
|
|
44
|
+
'interface',
|
|
45
|
+
'let',
|
|
46
|
+
'long',
|
|
47
|
+
'native',
|
|
48
|
+
'new',
|
|
49
|
+
'null',
|
|
50
|
+
'package',
|
|
51
|
+
'private',
|
|
52
|
+
'protected',
|
|
53
|
+
'public',
|
|
54
|
+
'return',
|
|
55
|
+
'short',
|
|
56
|
+
'static',
|
|
57
|
+
'super',
|
|
58
|
+
'switch',
|
|
59
|
+
'synchronized',
|
|
60
|
+
'this',
|
|
61
|
+
'throw',
|
|
62
|
+
'throws',
|
|
63
|
+
'transient',
|
|
64
|
+
'true',
|
|
65
|
+
'try',
|
|
66
|
+
'typeof',
|
|
67
|
+
'undefined',
|
|
68
|
+
'var',
|
|
69
|
+
'void',
|
|
70
|
+
'volatile',
|
|
71
|
+
'while',
|
|
72
|
+
'with',
|
|
73
|
+
'yield',
|
|
74
|
+
// TypeScript additional reserved words in strict mode
|
|
75
|
+
'as',
|
|
76
|
+
'any',
|
|
77
|
+
'declare',
|
|
78
|
+
'get',
|
|
79
|
+
'module',
|
|
80
|
+
'require',
|
|
81
|
+
'number',
|
|
82
|
+
'object',
|
|
83
|
+
'set',
|
|
84
|
+
'string',
|
|
85
|
+
'symbol',
|
|
86
|
+
'type',
|
|
87
|
+
'from',
|
|
88
|
+
'of',
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ensure a name is a valid JS/TS identifier:
|
|
93
|
+
* - Not a reserved word
|
|
94
|
+
* - Does not start with a digit
|
|
95
|
+
* - Does not contain numeric separator patterns (e.g. 1_000)
|
|
96
|
+
*/
|
|
97
|
+
export function sanitizeIdentifier(name: string): string {
|
|
98
|
+
// Strip numeric separator patterns like 1_000 → 1000
|
|
99
|
+
name = name.replace(/(\d)_(\d)/g, '$1$2')
|
|
100
|
+
|
|
101
|
+
// Strip any remaining invalid characters
|
|
102
|
+
name = name.replace(/[^a-zA-Z0-9_$]/g, '')
|
|
103
|
+
|
|
104
|
+
// Prefix with 'n' if name starts with a digit (not underscore, since
|
|
105
|
+
// pikku inspector may strip leading underscores from type names)
|
|
106
|
+
if (/^[0-9]/.test(name)) {
|
|
107
|
+
name = 'n' + name
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Prefix with underscore if name is a reserved word
|
|
111
|
+
if (JS_RESERVED_WORDS.has(name)) {
|
|
112
|
+
name = '_' + name
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return name || 'unnamed'
|
|
116
|
+
}
|
|
117
|
+
|
|
6
118
|
const IRREGULAR_PLURALS: Record<string, string> = {
|
|
7
119
|
addresses: 'address',
|
|
8
120
|
statuses: 'status',
|
|
@@ -27,11 +139,22 @@ export function singularize(word: string): string {
|
|
|
27
139
|
return word.slice(0, -3) + 'y'
|
|
28
140
|
}
|
|
29
141
|
// -shes, -ches, -xes, -zes, -sses → drop "es"
|
|
30
|
-
if (
|
|
142
|
+
if (
|
|
143
|
+
lower.endsWith('shes') ||
|
|
144
|
+
lower.endsWith('ches') ||
|
|
145
|
+
lower.endsWith('xes') ||
|
|
146
|
+
lower.endsWith('zes') ||
|
|
147
|
+
lower.endsWith('sses')
|
|
148
|
+
) {
|
|
31
149
|
return word.slice(0, -2)
|
|
32
150
|
}
|
|
33
151
|
// General: drop trailing "s" (covers courses→course, products→product, etc.)
|
|
34
|
-
if (
|
|
152
|
+
if (
|
|
153
|
+
lower.endsWith('s') &&
|
|
154
|
+
!lower.endsWith('ss') &&
|
|
155
|
+
!lower.endsWith('us') &&
|
|
156
|
+
lower.length > 2
|
|
157
|
+
) {
|
|
35
158
|
return word.slice(0, -1)
|
|
36
159
|
}
|
|
37
160
|
return word
|
|
@@ -40,6 +163,7 @@ export function singularize(word: string): string {
|
|
|
40
163
|
export function toCamelCase(str: string): string {
|
|
41
164
|
return str
|
|
42
165
|
.replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
|
|
166
|
+
.replace(/[^a-zA-Z0-9]+$/g, '') // strip trailing non-alphanumeric chars
|
|
43
167
|
.replace(/^[A-Z]/, (c) => c.toLowerCase())
|
|
44
168
|
}
|
|
45
169
|
|
|
@@ -84,12 +208,16 @@ export function detectCommonPrefix(paths: string[]): string {
|
|
|
84
208
|
/** Convert operationId to camelCase function name */
|
|
85
209
|
function fromOperationId(operationId: string): string {
|
|
86
210
|
// operationId might be snake_case, camelCase, PascalCase, or kebab-case
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
211
|
+
let name =
|
|
212
|
+
toCamelCase(
|
|
213
|
+
operationId
|
|
214
|
+
.replace(/[{}]/g, '') // strip curly braces from path parameters
|
|
215
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
216
|
+
.replace(/[-_]+/g, '_')
|
|
217
|
+
.replace(/^_|_$/g, '')
|
|
218
|
+
) || 'unnamed'
|
|
219
|
+
|
|
220
|
+
return sanitizeIdentifier(name)
|
|
93
221
|
}
|
|
94
222
|
|
|
95
223
|
const METHOD_PREFIXES: Record<string, string> = {
|
|
@@ -123,6 +251,11 @@ export function generateOperationNames(
|
|
|
123
251
|
): NamedOperation[] {
|
|
124
252
|
const results: NamedOperation[] = []
|
|
125
253
|
const usedNames = new Map<string, number>()
|
|
254
|
+
// Track names case-insensitively to prevent file system collisions on
|
|
255
|
+
// case-insensitive systems (macOS, Windows). Two operationIds that differ
|
|
256
|
+
// only in casing (e.g. userGetOauth2Application vs userGetOAuth2Application)
|
|
257
|
+
// would produce files that collide on disk.
|
|
258
|
+
const usedNamesLower = new Map<string, number>()
|
|
126
259
|
|
|
127
260
|
for (const op of operations) {
|
|
128
261
|
let name: string
|
|
@@ -133,13 +266,24 @@ export function generateOperationNames(
|
|
|
133
266
|
name = deriveNameFromPath(op.method, op.path, commonPrefix)
|
|
134
267
|
}
|
|
135
268
|
|
|
136
|
-
//
|
|
137
|
-
|
|
269
|
+
// Ensure name is a valid JS/TS identifier
|
|
270
|
+
name = sanitizeIdentifier(name)
|
|
271
|
+
|
|
272
|
+
// Handle collisions (case-insensitive to avoid file system conflicts)
|
|
273
|
+
const nameLower = name.toLowerCase()
|
|
274
|
+
const existingExact = usedNames.get(name) ?? 0
|
|
275
|
+
const existingCI = usedNamesLower.get(nameLower) ?? 0
|
|
276
|
+
const existing = Math.max(existingExact, existingCI)
|
|
138
277
|
if (existing > 0) {
|
|
139
278
|
usedNames.set(name, existing + 1)
|
|
279
|
+
usedNamesLower.set(nameLower, existing + 1)
|
|
140
280
|
name = `${name}${existing + 1}`
|
|
141
281
|
}
|
|
142
282
|
usedNames.set(name, (usedNames.get(name) ?? 0) || 1)
|
|
283
|
+
usedNamesLower.set(
|
|
284
|
+
name.toLowerCase(),
|
|
285
|
+
(usedNamesLower.get(name.toLowerCase()) ?? 0) || 1
|
|
286
|
+
)
|
|
143
287
|
|
|
144
288
|
results.push({
|
|
145
289
|
method: op.method,
|
|
@@ -163,10 +307,7 @@ function deriveNameFromPath(
|
|
|
163
307
|
cleanPath = '/' + cleanPath.slice(commonPrefix.length)
|
|
164
308
|
}
|
|
165
309
|
|
|
166
|
-
const segments = cleanPath
|
|
167
|
-
.replace(/^\//, '')
|
|
168
|
-
.split('/')
|
|
169
|
-
.filter(Boolean)
|
|
310
|
+
const segments = cleanPath.replace(/^\//, '').split('/').filter(Boolean)
|
|
170
311
|
|
|
171
312
|
const methodLower = method.toLowerCase()
|
|
172
313
|
const prefix = METHOD_PREFIXES[methodLower] || methodLower
|