@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/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 (lower.endsWith('shes') || lower.endsWith('ches') || lower.endsWith('xes') || lower.endsWith('zes') || lower.endsWith('sses')) {
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 (lower.endsWith('s') && !lower.endsWith('ss') && !lower.endsWith('us') && lower.length > 2) {
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
- return toCamelCase(
88
- operationId
89
- .replace(/[^a-zA-Z0-9_-]/g, '_')
90
- .replace(/_+/g, '_')
91
- .replace(/^_|_$/g, '')
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
- // Handle collisions
137
- const existing = usedNames.get(name) ?? 0
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