@pikku/openapi-parser 0.12.20 → 0.12.22
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 +37 -0
- package/dist/auth-config.d.ts +3 -3
- package/dist/codegen.js +32 -21
- package/package.json +8 -6
- package/src/auth-config.ts +0 -94
- package/src/codegen.test.ts +0 -973
- package/src/codegen.ts +0 -1670
- package/src/index.ts +0 -24
- package/src/naming.test.ts +0 -260
- package/src/naming.ts +0 -369
- package/src/openapi-to-zod-schema.test.ts +0 -664
- package/src/openapi-to-zod-schema.ts +0 -583
- package/src/parse-openapi.test.ts +0 -316
- package/src/parse-openapi.ts +0 -467
- package/src/zod-codegen.ts +0 -9
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts
DELETED
|
@@ -1,24 +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 { type NamedOperation } from './naming.js'
|
|
16
|
-
|
|
17
|
-
export {
|
|
18
|
-
type OpenAPISchema,
|
|
19
|
-
type ZodCodegenContext,
|
|
20
|
-
createContext,
|
|
21
|
-
schemaToZod,
|
|
22
|
-
schemaVarName,
|
|
23
|
-
sanitizeTypeName,
|
|
24
|
-
} from './openapi-to-zod-schema.js'
|
package/src/naming.test.ts
DELETED
|
@@ -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
|
-
}
|