@pikku/openapi-parser 0.12.19 → 0.12.21

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 DELETED
@@ -1,26 +0,0 @@
1
- export {
2
- parseOpenAPISpec,
3
- computeContractHash,
4
- type ParsedSpec,
5
- } from './parse-openapi.js'
6
-
7
- export { generateAddonFromOpenAPI } from './codegen.js'
8
-
9
- export {
10
- loadAuthConfig,
11
- type AuthConfig,
12
- type DelegatedLoginConfig,
13
- } from './auth-config.js'
14
-
15
- export {
16
- type NamedOperation,
17
- } from './naming.js'
18
-
19
- export {
20
- type OpenAPISchema,
21
- type ZodCodegenContext,
22
- createContext,
23
- schemaToZod,
24
- schemaVarName,
25
- sanitizeTypeName,
26
- } from './openapi-to-zod-schema.js'
@@ -1,260 +0,0 @@
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 DELETED
@@ -1,369 +0,0 @@
1
- /**
2
- * Derives function/method names from HTTP method + path.
3
- * Uses operationId when available, otherwise generates from path segments.
4
- */
5
-
6
- /**
7
- * Words that are illegal as a `const`/binding identifier in a strict-mode module
8
- * (generated code is always ESM). Contextual keywords that are only special in a
9
- * specific position — `get`/`set` (getters), `type`/`as`/`from`/`of` (type & import
10
- * syntax), `any`/`number`/`object`/`string`/`symbol`/`declare`/`module`/`require`
11
- * (type positions), and the legacy ES3 future-reserved words (`byte`, `char`, `int`,
12
- * `long`, `short`, `float`, `double`, `boolean`, `goto`, `native`, `synchronized`,
13
- * `transient`, `volatile`, `abstract`, `final`) — are all valid binding names and
14
- * deliberately excluded, so `sheets.spreadsheets.get` stays `get`, not `_get`.
15
- */
16
- const JS_RESERVED_WORDS = new Set([
17
- 'arguments',
18
- 'await',
19
- 'break',
20
- 'case',
21
- 'catch',
22
- 'class',
23
- 'const',
24
- 'continue',
25
- 'debugger',
26
- 'default',
27
- 'delete',
28
- 'do',
29
- 'else',
30
- 'enum',
31
- 'eval',
32
- 'export',
33
- 'extends',
34
- 'false',
35
- 'finally',
36
- 'for',
37
- 'function',
38
- 'if',
39
- 'implements',
40
- 'import',
41
- 'in',
42
- 'instanceof',
43
- 'interface',
44
- 'let',
45
- 'new',
46
- 'null',
47
- 'package',
48
- 'private',
49
- 'protected',
50
- 'public',
51
- 'return',
52
- 'static',
53
- 'super',
54
- 'switch',
55
- 'this',
56
- 'throw',
57
- 'throws',
58
- 'true',
59
- 'try',
60
- 'typeof',
61
- 'undefined',
62
- 'var',
63
- 'void',
64
- 'while',
65
- 'with',
66
- 'yield',
67
- ])
68
-
69
- /**
70
- * Ensure a name is a valid JS/TS identifier:
71
- * - Not a reserved word
72
- * - Does not start with a digit
73
- * - Does not contain numeric separator patterns (e.g. 1_000)
74
- */
75
- export function sanitizeIdentifier(name: string): string {
76
- // Strip numeric separator patterns like 1_000 → 1000
77
- name = name.replace(/(\d)_(\d)/g, '$1$2')
78
-
79
- // Strip any remaining invalid characters
80
- name = name.replace(/[^a-zA-Z0-9_$]/g, '')
81
-
82
- // Prefix with 'n' if name starts with a digit (not underscore, since
83
- // pikku inspector may strip leading underscores from type names)
84
- if (/^[0-9]/.test(name)) {
85
- name = 'n' + name
86
- }
87
-
88
- // Prefix with underscore if name is a reserved word
89
- if (JS_RESERVED_WORDS.has(name)) {
90
- name = '_' + name
91
- }
92
-
93
- return name || 'unnamed'
94
- }
95
-
96
- const IRREGULAR_PLURALS: Record<string, string> = {
97
- addresses: 'address',
98
- statuses: 'status',
99
- indices: 'index',
100
- analyses: 'analysis',
101
- quizzes: 'quiz',
102
- matrices: 'matrix',
103
- vertices: 'vertex',
104
- aliases: 'alias',
105
- buses: 'bus',
106
- }
107
-
108
- export function singularize(word: string): string {
109
- const lower = word.toLowerCase()
110
- if (IRREGULAR_PLURALS[lower]) {
111
- const singular = IRREGULAR_PLURALS[lower]
112
- return word[0] === word[0].toUpperCase()
113
- ? singular.charAt(0).toUpperCase() + singular.slice(1)
114
- : singular
115
- }
116
- if (lower.endsWith('ies') && lower.length > 4) {
117
- return word.slice(0, -3) + 'y'
118
- }
119
- // -shes, -ches, -xes, -zes, -sses → drop "es"
120
- if (
121
- lower.endsWith('shes') ||
122
- lower.endsWith('ches') ||
123
- lower.endsWith('xes') ||
124
- lower.endsWith('zes') ||
125
- lower.endsWith('sses')
126
- ) {
127
- return word.slice(0, -2)
128
- }
129
- // General: drop trailing "s" (covers courses→course, products→product, etc.)
130
- if (
131
- lower.endsWith('s') &&
132
- !lower.endsWith('ss') &&
133
- !lower.endsWith('us') &&
134
- lower.length > 2
135
- ) {
136
- return word.slice(0, -1)
137
- }
138
- return word
139
- }
140
-
141
- export function toCamelCase(str: string): string {
142
- return str
143
- .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
144
- .replace(/[^a-zA-Z0-9]+$/g, '') // strip trailing non-alphanumeric chars
145
- .replace(/^[A-Z]/, (c) => c.toLowerCase())
146
- }
147
-
148
- export function toPascalCase(str: string): string {
149
- const camel = toCamelCase(str)
150
- return camel.charAt(0).toUpperCase() + camel.slice(1)
151
- }
152
-
153
- /**
154
- * Strip common API prefix from paths (e.g. /api/v2/).
155
- * Uses majority voting — a prefix shared by >75% of paths is stripped,
156
- * so a few outlier paths (e.g. /oauth/...) don't break detection.
157
- */
158
- export function detectCommonPrefix(paths: string[]): string {
159
- if (paths.length === 0) return ''
160
-
161
- const segments = paths.map((p) => p.replace(/^\//, '').split('/'))
162
- const threshold = Math.ceil(paths.length * 0.75)
163
-
164
- // Find the longest prefix shared by at least `threshold` paths
165
- let prefixLen = 0
166
- for (let i = 0; i < 10; i++) {
167
- // Count how many paths have the same segment at position i as the first path
168
- const candidateSeg = segments[0][i]
169
- if (!candidateSeg || candidateSeg.startsWith('{')) break
170
-
171
- const count = segments.filter(
172
- (s) => s.length > i + 1 && s[i] === candidateSeg
173
- ).length
174
-
175
- if (count >= threshold) {
176
- prefixLen = i + 1
177
- } else {
178
- break
179
- }
180
- }
181
-
182
- if (prefixLen === 0) return ''
183
- return '/' + segments[0].slice(0, prefixLen).join('/') + '/'
184
- }
185
-
186
- /** Convert operationId to camelCase function name */
187
- function fromOperationId(operationId: string): string {
188
- // operationId might be snake_case, camelCase, PascalCase, or kebab-case
189
- let name =
190
- toCamelCase(
191
- operationId
192
- .replace(/[{}]/g, '') // strip curly braces from path parameters
193
- .replace(/[^a-zA-Z0-9_-]/g, '_')
194
- .replace(/[-_]+/g, '_')
195
- .replace(/^_|_$/g, '')
196
- ) || 'unnamed'
197
-
198
- return sanitizeIdentifier(name)
199
- }
200
-
201
- const METHOD_PREFIXES: Record<string, string> = {
202
- get: 'get',
203
- post: 'create',
204
- put: 'update',
205
- patch: 'update',
206
- delete: 'delete',
207
- }
208
-
209
- interface OperationForNaming {
210
- method: string
211
- path: string
212
- operationId?: string
213
- }
214
-
215
- /**
216
- * Find the longest shared leading run of dot-separated operationId segments,
217
- * always leaving at least one trailing segment on every operation. Only applies
218
- * when every operation has a dotted operationId (e.g. Google's
219
- * `sheets.spreadsheets.values.get`), so specs whose ids are camelCase or absent
220
- * are left untouched. The shared prefix is redundant with the addon namespace,
221
- * so stripping it turns `sheetsSpreadsheetsValuesGet` into `valuesGet`.
222
- */
223
- function commonOperationIdPrefix(operations: OperationForNaming[]): string[] {
224
- if (operations.length < 2) {
225
- return []
226
- }
227
- if (
228
- !operations.every((op) => op.operationId && op.operationId.includes('.'))
229
- ) {
230
- return []
231
- }
232
- const segmented = operations.map((op) => op.operationId!.split('.'))
233
- const minLen = Math.min(...segmented.map((s) => s.length))
234
- const prefix: string[] = []
235
- for (let i = 0; i < minLen - 1; i++) {
236
- const seg = segmented[0][i]
237
- if (segmented.every((s) => s[i] === seg)) {
238
- prefix.push(seg)
239
- } else {
240
- break
241
- }
242
- }
243
- return prefix
244
- }
245
-
246
- export interface NamedOperation {
247
- method: string
248
- path: string
249
- functionName: string
250
- methodName: string
251
- }
252
-
253
- /**
254
- * Generate function names for a list of operations.
255
- * Handles collision detection and auto-deduplication.
256
- */
257
- export function generateOperationNames(
258
- operations: OperationForNaming[],
259
- commonPrefix: string
260
- ): NamedOperation[] {
261
- const results: NamedOperation[] = []
262
- const usedNames = new Map<string, number>()
263
- // Track names case-insensitively to prevent file system collisions on
264
- // case-insensitive systems (macOS, Windows). Two operationIds that differ
265
- // only in casing (e.g. userGetOauth2Application vs userGetOAuth2Application)
266
- // would produce files that collide on disk.
267
- const usedNamesLower = new Map<string, number>()
268
-
269
- const opIdPrefix = commonOperationIdPrefix(operations)
270
-
271
- for (const op of operations) {
272
- let name: string
273
-
274
- if (op.operationId) {
275
- const opId = opIdPrefix.length
276
- ? op.operationId.split('.').slice(opIdPrefix.length).join('.')
277
- : op.operationId
278
- name = fromOperationId(opId)
279
- } else {
280
- name = deriveNameFromPath(op.method, op.path, commonPrefix)
281
- }
282
-
283
- // Ensure name is a valid JS/TS identifier
284
- name = sanitizeIdentifier(name)
285
-
286
- // Handle collisions (case-insensitive to avoid file system conflicts)
287
- const nameLower = name.toLowerCase()
288
- const existingExact = usedNames.get(name) ?? 0
289
- const existingCI = usedNamesLower.get(nameLower) ?? 0
290
- const existing = Math.max(existingExact, existingCI)
291
- if (existing > 0) {
292
- usedNames.set(name, existing + 1)
293
- usedNamesLower.set(nameLower, existing + 1)
294
- name = `${name}${existing + 1}`
295
- }
296
- usedNames.set(name, (usedNames.get(name) ?? 0) || 1)
297
- usedNamesLower.set(
298
- name.toLowerCase(),
299
- (usedNamesLower.get(name.toLowerCase()) ?? 0) || 1
300
- )
301
-
302
- results.push({
303
- method: op.method,
304
- path: op.path,
305
- functionName: name,
306
- methodName: name,
307
- })
308
- }
309
-
310
- return results
311
- }
312
-
313
- function deriveNameFromPath(
314
- method: string,
315
- path: string,
316
- commonPrefix: string
317
- ): string {
318
- // Strip common prefix
319
- let cleanPath = path
320
- if (commonPrefix && cleanPath.startsWith(commonPrefix)) {
321
- cleanPath = '/' + cleanPath.slice(commonPrefix.length)
322
- }
323
-
324
- const segments = cleanPath.replace(/^\//, '').split('/').filter(Boolean)
325
-
326
- const methodLower = method.toLowerCase()
327
- const prefix = METHOD_PREFIXES[methodLower] || methodLower
328
-
329
- // Separate param and non-param segments
330
- const nonParams: string[] = []
331
- let hasTrailingParam = false
332
-
333
- for (let i = 0; i < segments.length; i++) {
334
- if (segments[i].startsWith('{')) {
335
- if (i === segments.length - 1) {
336
- hasTrailingParam = true
337
- }
338
- } else {
339
- nonParams.push(segments[i])
340
- }
341
- }
342
-
343
- if (nonParams.length === 0) {
344
- return prefix
345
- }
346
-
347
- // For GET with trailing param: singularize the last non-param segment
348
- // For GET without trailing param: keep plural (list)
349
- // For POST without trailing param: use prefix (create)
350
- const parts = nonParams.map((seg, i) => {
351
- const isLast = i === nonParams.length - 1
352
- let word = seg
353
-
354
- if (isLast && hasTrailingParam) {
355
- word = singularize(word)
356
- }
357
-
358
- // For GET on collection (no trailing param), use "list" prefix instead of "get"
359
- return toPascalCase(word)
360
- })
361
-
362
- // For GET on collection, use "list" instead of "get"
363
- let finalPrefix = prefix
364
- if (methodLower === 'get' && !hasTrailingParam) {
365
- finalPrefix = 'list'
366
- }
367
-
368
- return finalPrefix + parts.join('')
369
- }