@pikku/openapi-parser 0.12.15 → 0.12.17
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 +56 -0
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/codegen.js +73 -31
- package/dist/naming.js +44 -33
- package/package.json +1 -1
- package/src/codegen.test.ts +9 -3
- package/src/codegen.ts +108 -57
- package/src/naming.ts +47 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# @pikku/openapi-parser
|
|
2
2
|
|
|
3
|
+
## 0.12.17
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a7b26c5: rename the inspected declarations to `define*`: `wireScope` → `defineScope`, `wireSecret` → `defineSecret`, `wireVariable` → `defineVariable`, `wireCredential` → `defineCredential`
|
|
8
|
+
|
|
9
|
+
`wire*` meant two unrelated things. A transport wiring attaches a function to
|
|
10
|
+
something that can invoke it — `wireHTTP`, `wireChannel`, `wireScheduler`,
|
|
11
|
+
`wireQueueWorker` and the rest — and the thing it wires runs. These four wire
|
|
12
|
+
nothing: they are no-ops that exist only so the call typechecks, they are
|
|
13
|
+
tree-shaken out of the build, and their whole job is to be found by the
|
|
14
|
+
inspector's AST pass and turned into a type union. One word for both left the
|
|
15
|
+
declaration reading like a registration with a runtime.
|
|
16
|
+
|
|
17
|
+
So the vocabulary splits: **`wire*` is a transport, `define*` is an inspected
|
|
18
|
+
declaration.**
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { defineScope } from '@pikku/core/scope'
|
|
22
|
+
import { defineSecret } from '@pikku/core/secret'
|
|
23
|
+
import { defineVariable } from '@pikku/core/variable'
|
|
24
|
+
import { defineCredential } from '@pikku/core/credential'
|
|
25
|
+
|
|
26
|
+
defineScope({ admin: { scopes: { invoices: { scopes: { create: {} } } } } })
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Breaking:** no alias is kept. Rename the four call sites; the module subpaths
|
|
30
|
+
(`@pikku/core/scope`, `/secret`, `/variable`) are unchanged.
|
|
31
|
+
|
|
32
|
+
The inspector matches these by identifier text, so a stale `wire*` call is not a
|
|
33
|
+
type error — it is silently not extracted, and the generated union comes back
|
|
34
|
+
empty. That fails as "this scope isn't declared" on code that was fine a moment
|
|
35
|
+
ago, nowhere near the declaration. Grep for the old names rather than trusting a
|
|
36
|
+
clean build.
|
|
37
|
+
|
|
38
|
+
An addon published with `.pikku` output generated before this release re-exports
|
|
39
|
+
`wireSecret` from `@pikku/core/secret` and will not typecheck against this core
|
|
40
|
+
until it is rebuilt and republished.
|
|
41
|
+
|
|
42
|
+
## 0.12.16
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- 9f0d0eb: Migrate the `--oauth` addon scaffold off `OAuth2Client`. A scaffolded OAuth2
|
|
47
|
+
addon service used to construct `new OAuth2Client(config, appCredentialSecretId,
|
|
48
|
+
secrets)` and do its own token exchange/refresh — the responsibility better-auth
|
|
49
|
+
now owns via the credential service. The `pikku new addon --oauth` scaffold (and
|
|
50
|
+
the OpenAPI `--openapi` generator) now emit a service that receives a ready
|
|
51
|
+
access token: `services.ts` uses `createWireServices` + `wire.getCredential<{
|
|
52
|
+
accessToken: string }>(name)` and the service does a plain `fetch` with
|
|
53
|
+
`Authorization: Bearer ${accessToken}`, matching the existing per-user
|
|
54
|
+
bearer/apikey credential scaffold. With no remaining consumers, `OAuth2Client`
|
|
55
|
+
(`@pikku/core/oauth2`) and its test are removed; the `./oauth2` export keeps the
|
|
56
|
+
`OAuth2AppCredential` / `OAuth2Token` types.
|
|
57
|
+
- 8601505: Make `wireCredential` the single source of truth for an addon's OAuth2 config: `pikku-credentials.gen.ts` exports `CREDENTIAL_OAUTH2_CONFIGS`, generated services import from it, the OpenAPI importer emits a `wireCredential`, and the inspector now extracts `oauth2.additionalParams`.
|
|
58
|
+
|
|
3
59
|
## 0.12.15
|
|
4
60
|
|
|
5
61
|
### Patch Changes
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 - present Yasser Fadl and Pikku contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @pikku/openapi-parser
|
|
2
|
+
|
|
3
|
+
Parses OpenAPI specs into a normalised shape and generates Pikku addon code
|
|
4
|
+
from them — operations, parameters, error responses and security schemes.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -D @pikku/openapi-parser
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { parseOpenAPISpec, computeContractHash } from '@pikku/openapi-parser'
|
|
16
|
+
|
|
17
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
18
|
+
const hash = computeContractHash(spec)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The contract hash lets generated addons detect when an upstream spec changed.
|
|
22
|
+
|
|
23
|
+
## Docs
|
|
24
|
+
|
|
25
|
+
https://pikku.dev/docs
|
package/dist/codegen.js
CHANGED
|
@@ -62,7 +62,8 @@ function humanDescription(parsed) {
|
|
|
62
62
|
if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
|
|
63
63
|
return capitalize(summary);
|
|
64
64
|
}
|
|
65
|
-
return humanizeOperationId(parsed.operationId) ??
|
|
65
|
+
return (humanizeOperationId(parsed.operationId) ??
|
|
66
|
+
`${parsed.method.toUpperCase()} ${parsed.path}`);
|
|
66
67
|
}
|
|
67
68
|
function getErrorClassesForResponses(errorResponses) {
|
|
68
69
|
const classes = [];
|
|
@@ -293,10 +294,15 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
293
294
|
}
|
|
294
295
|
// Generate index.ts with all exports
|
|
295
296
|
files['src/index.ts'] = generateIndexFile(functionExports, {
|
|
296
|
-
upstreamAuth: upstreamAuthExport
|
|
297
|
+
upstreamAuth: upstreamAuthExport
|
|
298
|
+
? { name, export: upstreamAuthExport }
|
|
299
|
+
: undefined,
|
|
297
300
|
});
|
|
298
301
|
// Generate typed API service class with route map
|
|
299
302
|
files[`src/${name}-api.service.ts`] = generateServiceFile(spec, opPairs, vars, flags);
|
|
303
|
+
if (flags.oauth || flags.credential === 'oauth2') {
|
|
304
|
+
files[`src/${name}.credential.ts`] = generateCredentialFile(spec, vars);
|
|
305
|
+
}
|
|
300
306
|
// Generate variable file for BASE_URL
|
|
301
307
|
files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars);
|
|
302
308
|
return files;
|
|
@@ -935,8 +941,8 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
935
941
|
// Per-user credential: no special imports needed, creds passed via constructor
|
|
936
942
|
}
|
|
937
943
|
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
938
|
-
|
|
939
|
-
|
|
944
|
+
// OAuth2: the access token is resolved via the credential service and passed
|
|
945
|
+
// via the constructor — no special imports needed.
|
|
940
946
|
}
|
|
941
947
|
else if (flags.secret) {
|
|
942
948
|
lines.push(`import type { ${pascalName}Secrets } from './${name}.secret.js'`);
|
|
@@ -946,23 +952,6 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
946
952
|
}
|
|
947
953
|
lines.push(`import type { TypedVariablesService } from '#pikku/variables/pikku-variables.gen.js'`);
|
|
948
954
|
lines.push('');
|
|
949
|
-
if (flags.oauth) {
|
|
950
|
-
// Use OAuth2 details from spec if available
|
|
951
|
-
const oauthScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'oauth2');
|
|
952
|
-
const authUrl = oauthScheme?.flows?.authorizationUrl ??
|
|
953
|
-
'https://example.com/oauth2/authorize';
|
|
954
|
-
const tokenUrl = oauthScheme?.flows?.tokenUrl ?? 'https://example.com/oauth2/token';
|
|
955
|
-
const scopes = oauthScheme?.flows?.scopes
|
|
956
|
-
? Object.keys(oauthScheme.flows.scopes)
|
|
957
|
-
: ['read', 'write'];
|
|
958
|
-
lines.push(`export const ${screamingName}_OAUTH2_CONFIG = {`);
|
|
959
|
-
lines.push(` tokenSecretId: '${screamingName}_TOKENS',`);
|
|
960
|
-
lines.push(` authorizationUrl: ${JSON.stringify(authUrl)},`);
|
|
961
|
-
lines.push(` tokenUrl: ${JSON.stringify(tokenUrl)},`);
|
|
962
|
-
lines.push(` scopes: ${JSON.stringify(scopes)},`);
|
|
963
|
-
lines.push('}');
|
|
964
|
-
lines.push('');
|
|
965
|
-
}
|
|
966
955
|
// Generate route map from parsed operations
|
|
967
956
|
const routes = {};
|
|
968
957
|
for (const { parsed } of opPairs) {
|
|
@@ -1007,15 +996,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
1007
996
|
lines.push(' }');
|
|
1008
997
|
}
|
|
1009
998
|
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1010
|
-
lines.push(' private oauth: OAuth2Client');
|
|
1011
999
|
lines.push('');
|
|
1012
|
-
lines.push(` constructor(
|
|
1000
|
+
lines.push(` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`);
|
|
1013
1001
|
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1014
|
-
lines.push(' this.oauth = new OAuth2Client(');
|
|
1015
|
-
lines.push(` ${screamingName}_OAUTH2_CONFIG,`);
|
|
1016
|
-
lines.push(` '${screamingName}_APP_CREDENTIALS',`);
|
|
1017
|
-
lines.push(' secrets');
|
|
1018
|
-
lines.push(' )');
|
|
1019
1002
|
lines.push(' }');
|
|
1020
1003
|
}
|
|
1021
1004
|
else if (flags.secret) {
|
|
@@ -1122,7 +1105,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
1122
1105
|
lines.push(' })');
|
|
1123
1106
|
}
|
|
1124
1107
|
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1125
|
-
lines.push('
|
|
1108
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`');
|
|
1109
|
+
lines.push('');
|
|
1110
|
+
lines.push(' const response = await fetch(url.toString(), {');
|
|
1126
1111
|
lines.push(' method,');
|
|
1127
1112
|
lines.push(' headers,');
|
|
1128
1113
|
lines.push(' body: body ? JSON.stringify(body) : undefined,');
|
|
@@ -1187,6 +1172,63 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
1187
1172
|
lines.push('');
|
|
1188
1173
|
return lines.join('\n');
|
|
1189
1174
|
}
|
|
1175
|
+
function generateCredentialFile(spec, vars) {
|
|
1176
|
+
const { camelName, screamingName } = vars;
|
|
1177
|
+
const displayName = vars.displayName.replace(/'/g, '');
|
|
1178
|
+
const description = vars.description.replace(/'/g, '');
|
|
1179
|
+
const oauthScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'oauth2');
|
|
1180
|
+
const authorizationUrl = oauthScheme?.flows?.authorizationUrl;
|
|
1181
|
+
const tokenUrl = oauthScheme?.flows?.tokenUrl;
|
|
1182
|
+
const scopes = oauthScheme?.flows?.scopes
|
|
1183
|
+
? Object.keys(oauthScheme.flows.scopes)
|
|
1184
|
+
: ['read', 'write'];
|
|
1185
|
+
const lines = [];
|
|
1186
|
+
lines.push("import { z } from 'zod'");
|
|
1187
|
+
lines.push("import { defineCredential } from '@pikku/core/credential'");
|
|
1188
|
+
lines.push("import { defineSecret } from '@pikku/core/secret'");
|
|
1189
|
+
lines.push('');
|
|
1190
|
+
lines.push(`export const ${camelName}TokenSchema = z.object({`);
|
|
1191
|
+
lines.push(' accessToken: z.string(),');
|
|
1192
|
+
lines.push(' refreshToken: z.string().optional(),');
|
|
1193
|
+
lines.push('})');
|
|
1194
|
+
lines.push('');
|
|
1195
|
+
lines.push(`export const ${camelName}OAuthAppSchema = z.object({`);
|
|
1196
|
+
lines.push(" clientId: z.string().describe('OAuth2 app client ID'),");
|
|
1197
|
+
lines.push(" clientSecret: z.string().describe('OAuth2 app client secret'),");
|
|
1198
|
+
lines.push('})');
|
|
1199
|
+
lines.push('');
|
|
1200
|
+
if (!authorizationUrl || !tokenUrl) {
|
|
1201
|
+
lines.push('// TODO: this spec declares no oauth2 flow URLs, so the placeholder(s)');
|
|
1202
|
+
lines.push(`// below are not real. Replace them with ${displayName}'s endpoints —`);
|
|
1203
|
+
lines.push('// OAuth will fail against example.com.');
|
|
1204
|
+
}
|
|
1205
|
+
// oauth2 must stay an inline object literal: the inspector reads it via AST
|
|
1206
|
+
// (add-credential.ts) and silently drops a config behind an identifier.
|
|
1207
|
+
lines.push('defineCredential({');
|
|
1208
|
+
lines.push(` name: '${camelName}',`);
|
|
1209
|
+
lines.push(` displayName: '${displayName}',`);
|
|
1210
|
+
lines.push(` description: '${description}',`);
|
|
1211
|
+
lines.push(` type: 'wire',`);
|
|
1212
|
+
lines.push(` schema: ${camelName}TokenSchema,`);
|
|
1213
|
+
lines.push(' oauth2: {');
|
|
1214
|
+
lines.push(` appCredentialSecretId: '${screamingName}_OAUTH_APP',`);
|
|
1215
|
+
lines.push(` tokenSecretId: '${screamingName}_OAUTH_TOKENS',`);
|
|
1216
|
+
lines.push(` authorizationUrl: ${JSON.stringify(authorizationUrl ?? 'https://example.com/oauth2/authorize')},`);
|
|
1217
|
+
lines.push(` tokenUrl: ${JSON.stringify(tokenUrl ?? 'https://example.com/oauth2/token')},`);
|
|
1218
|
+
lines.push(` scopes: ${JSON.stringify(scopes)},`);
|
|
1219
|
+
lines.push(' },');
|
|
1220
|
+
lines.push('})');
|
|
1221
|
+
lines.push('');
|
|
1222
|
+
lines.push('defineSecret({');
|
|
1223
|
+
lines.push(` name: '${camelName}OAuthApp',`);
|
|
1224
|
+
lines.push(` displayName: '${displayName} OAuth App',`);
|
|
1225
|
+
lines.push(` description: 'OAuth2 app credentials for ${displayName}',`);
|
|
1226
|
+
lines.push(` secretId: '${screamingName}_OAUTH_APP',`);
|
|
1227
|
+
lines.push(` schema: ${camelName}OAuthAppSchema,`);
|
|
1228
|
+
lines.push('})');
|
|
1229
|
+
lines.push('');
|
|
1230
|
+
return lines.join('\n');
|
|
1231
|
+
}
|
|
1190
1232
|
function generateVariableFile(spec, vars) {
|
|
1191
1233
|
const { camelName, screamingName } = vars;
|
|
1192
1234
|
const displayName = vars.displayName.replace(/'/g, '');
|
|
@@ -1194,7 +1236,7 @@ function generateVariableFile(spec, vars) {
|
|
|
1194
1236
|
const defaultUrl = serverUrls[0];
|
|
1195
1237
|
const lines = [];
|
|
1196
1238
|
lines.push("import { z } from 'zod'");
|
|
1197
|
-
lines.push("import {
|
|
1239
|
+
lines.push("import { defineVariable } from '@pikku/core/variable'");
|
|
1198
1240
|
lines.push('');
|
|
1199
1241
|
const schemaVarName = `${camelName}BaseUrlSchema`;
|
|
1200
1242
|
if (serverUrls.length > 0) {
|
|
@@ -1205,7 +1247,7 @@ function generateVariableFile(spec, vars) {
|
|
|
1205
1247
|
lines.push(`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`);
|
|
1206
1248
|
}
|
|
1207
1249
|
lines.push('');
|
|
1208
|
-
lines.push(`
|
|
1250
|
+
lines.push(`defineVariable({`);
|
|
1209
1251
|
lines.push(` name: '${screamingName}_BASE_URL',`);
|
|
1210
1252
|
lines.push(` displayName: '${displayName} Base URL',`);
|
|
1211
1253
|
lines.push(` description: 'The base URL for the ${displayName} API.',`);
|
package/dist/naming.js
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
* Derives function/method names from HTTP method + path.
|
|
3
3
|
* Uses operationId when available, otherwise generates from path segments.
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Words that are illegal as a `const`/binding identifier in a strict-mode module
|
|
7
|
+
* (generated code is always ESM). Contextual keywords that are only special in a
|
|
8
|
+
* specific position — `get`/`set` (getters), `type`/`as`/`from`/`of` (type & import
|
|
9
|
+
* syntax), `any`/`number`/`object`/`string`/`symbol`/`declare`/`module`/`require`
|
|
10
|
+
* (type positions), and the legacy ES3 future-reserved words (`byte`, `char`, `int`,
|
|
11
|
+
* `long`, `short`, `float`, `double`, `boolean`, `goto`, `native`, `synchronized`,
|
|
12
|
+
* `transient`, `volatile`, `abstract`, `final`) — are all valid binding names and
|
|
13
|
+
* deliberately excluded, so `sheets.spreadsheets.get` stays `get`, not `_get`.
|
|
14
|
+
*/
|
|
6
15
|
const JS_RESERVED_WORDS = new Set([
|
|
7
|
-
// ECMAScript reserved words
|
|
8
|
-
'abstract',
|
|
9
16
|
'arguments',
|
|
10
17
|
'await',
|
|
11
|
-
'boolean',
|
|
12
18
|
'break',
|
|
13
|
-
'byte',
|
|
14
19
|
'case',
|
|
15
20
|
'catch',
|
|
16
|
-
'char',
|
|
17
21
|
'class',
|
|
18
22
|
'const',
|
|
19
23
|
'continue',
|
|
@@ -21,29 +25,22 @@ const JS_RESERVED_WORDS = new Set([
|
|
|
21
25
|
'default',
|
|
22
26
|
'delete',
|
|
23
27
|
'do',
|
|
24
|
-
'double',
|
|
25
28
|
'else',
|
|
26
29
|
'enum',
|
|
27
30
|
'eval',
|
|
28
31
|
'export',
|
|
29
32
|
'extends',
|
|
30
33
|
'false',
|
|
31
|
-
'final',
|
|
32
34
|
'finally',
|
|
33
|
-
'float',
|
|
34
35
|
'for',
|
|
35
36
|
'function',
|
|
36
|
-
'goto',
|
|
37
37
|
'if',
|
|
38
38
|
'implements',
|
|
39
39
|
'import',
|
|
40
40
|
'in',
|
|
41
41
|
'instanceof',
|
|
42
|
-
'int',
|
|
43
42
|
'interface',
|
|
44
43
|
'let',
|
|
45
|
-
'long',
|
|
46
|
-
'native',
|
|
47
44
|
'new',
|
|
48
45
|
'null',
|
|
49
46
|
'package',
|
|
@@ -51,40 +48,21 @@ const JS_RESERVED_WORDS = new Set([
|
|
|
51
48
|
'protected',
|
|
52
49
|
'public',
|
|
53
50
|
'return',
|
|
54
|
-
'short',
|
|
55
51
|
'static',
|
|
56
52
|
'super',
|
|
57
53
|
'switch',
|
|
58
|
-
'synchronized',
|
|
59
54
|
'this',
|
|
60
55
|
'throw',
|
|
61
56
|
'throws',
|
|
62
|
-
'transient',
|
|
63
57
|
'true',
|
|
64
58
|
'try',
|
|
65
59
|
'typeof',
|
|
66
60
|
'undefined',
|
|
67
61
|
'var',
|
|
68
62
|
'void',
|
|
69
|
-
'volatile',
|
|
70
63
|
'while',
|
|
71
64
|
'with',
|
|
72
65
|
'yield',
|
|
73
|
-
// TypeScript additional reserved words in strict mode
|
|
74
|
-
'as',
|
|
75
|
-
'any',
|
|
76
|
-
'declare',
|
|
77
|
-
'get',
|
|
78
|
-
'module',
|
|
79
|
-
'require',
|
|
80
|
-
'number',
|
|
81
|
-
'object',
|
|
82
|
-
'set',
|
|
83
|
-
'string',
|
|
84
|
-
'symbol',
|
|
85
|
-
'type',
|
|
86
|
-
'from',
|
|
87
|
-
'of',
|
|
88
66
|
]);
|
|
89
67
|
/**
|
|
90
68
|
* Ensure a name is a valid JS/TS identifier:
|
|
@@ -203,6 +181,35 @@ const METHOD_PREFIXES = {
|
|
|
203
181
|
patch: 'update',
|
|
204
182
|
delete: 'delete',
|
|
205
183
|
};
|
|
184
|
+
/**
|
|
185
|
+
* Find the longest shared leading run of dot-separated operationId segments,
|
|
186
|
+
* always leaving at least one trailing segment on every operation. Only applies
|
|
187
|
+
* when every operation has a dotted operationId (e.g. Google's
|
|
188
|
+
* `sheets.spreadsheets.values.get`), so specs whose ids are camelCase or absent
|
|
189
|
+
* are left untouched. The shared prefix is redundant with the addon namespace,
|
|
190
|
+
* so stripping it turns `sheetsSpreadsheetsValuesGet` into `valuesGet`.
|
|
191
|
+
*/
|
|
192
|
+
function commonOperationIdPrefix(operations) {
|
|
193
|
+
if (operations.length < 2) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
if (!operations.every((op) => op.operationId && op.operationId.includes('.'))) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const segmented = operations.map((op) => op.operationId.split('.'));
|
|
200
|
+
const minLen = Math.min(...segmented.map((s) => s.length));
|
|
201
|
+
const prefix = [];
|
|
202
|
+
for (let i = 0; i < minLen - 1; i++) {
|
|
203
|
+
const seg = segmented[0][i];
|
|
204
|
+
if (segmented.every((s) => s[i] === seg)) {
|
|
205
|
+
prefix.push(seg);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return prefix;
|
|
212
|
+
}
|
|
206
213
|
/**
|
|
207
214
|
* Generate function names for a list of operations.
|
|
208
215
|
* Handles collision detection and auto-deduplication.
|
|
@@ -215,10 +222,14 @@ export function generateOperationNames(operations, commonPrefix) {
|
|
|
215
222
|
// only in casing (e.g. userGetOauth2Application vs userGetOAuth2Application)
|
|
216
223
|
// would produce files that collide on disk.
|
|
217
224
|
const usedNamesLower = new Map();
|
|
225
|
+
const opIdPrefix = commonOperationIdPrefix(operations);
|
|
218
226
|
for (const op of operations) {
|
|
219
227
|
let name;
|
|
220
228
|
if (op.operationId) {
|
|
221
|
-
|
|
229
|
+
const opId = opIdPrefix.length
|
|
230
|
+
? op.operationId.split('.').slice(opIdPrefix.length).join('.')
|
|
231
|
+
: op.operationId;
|
|
232
|
+
name = fromOperationId(opId);
|
|
222
233
|
}
|
|
223
234
|
else {
|
|
224
235
|
name = deriveNameFromPath(op.method, op.path, commonPrefix);
|
package/package.json
CHANGED
package/src/codegen.test.ts
CHANGED
|
@@ -801,7 +801,8 @@ describe('function description synthesis', () => {
|
|
|
801
801
|
secret: false,
|
|
802
802
|
mcp: true,
|
|
803
803
|
})
|
|
804
|
-
const funcFile =
|
|
804
|
+
const funcFile =
|
|
805
|
+
files['src/functions/contactsControllerGetContacts.function.ts']
|
|
805
806
|
assert.ok(funcFile, 'function file should exist')
|
|
806
807
|
assert.ok(
|
|
807
808
|
funcFile.includes('description: "Contacts get contacts"'),
|
|
@@ -812,7 +813,9 @@ describe('function description synthesis', () => {
|
|
|
812
813
|
|
|
813
814
|
test('falls back to "METHOD /path" when operationId is absent', () => {
|
|
814
815
|
const spec = makeSpec({
|
|
815
|
-
operations: [
|
|
816
|
+
operations: [
|
|
817
|
+
makeOp({ operationId: undefined, method: 'get', path: '/health' }),
|
|
818
|
+
],
|
|
816
819
|
})
|
|
817
820
|
|
|
818
821
|
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
@@ -830,7 +833,10 @@ describe('function description synthesis', () => {
|
|
|
830
833
|
test('prefers the spec description over synthesis', () => {
|
|
831
834
|
const spec = makeSpec({
|
|
832
835
|
operations: [
|
|
833
|
-
makeOp({
|
|
836
|
+
makeOp({
|
|
837
|
+
operationId: 'getPet',
|
|
838
|
+
description: 'Fetch a single pet by id',
|
|
839
|
+
}),
|
|
834
840
|
],
|
|
835
841
|
})
|
|
836
842
|
|
package/src/codegen.ts
CHANGED
|
@@ -105,7 +105,10 @@ function humanDescription(parsed: ParsedOperation): string {
|
|
|
105
105
|
if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
|
|
106
106
|
return capitalize(summary)
|
|
107
107
|
}
|
|
108
|
-
return
|
|
108
|
+
return (
|
|
109
|
+
humanizeOperationId(parsed.operationId) ??
|
|
110
|
+
`${parsed.method.toUpperCase()} ${parsed.path}`
|
|
111
|
+
)
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
function getErrorClassesForResponses(
|
|
@@ -433,7 +436,9 @@ export function generateAddonFromOpenAPI(
|
|
|
433
436
|
|
|
434
437
|
// Generate index.ts with all exports
|
|
435
438
|
files['src/index.ts'] = generateIndexFile(functionExports, {
|
|
436
|
-
upstreamAuth: upstreamAuthExport
|
|
439
|
+
upstreamAuth: upstreamAuthExport
|
|
440
|
+
? { name, export: upstreamAuthExport }
|
|
441
|
+
: undefined,
|
|
437
442
|
})
|
|
438
443
|
|
|
439
444
|
// Generate typed API service class with route map
|
|
@@ -444,6 +449,10 @@ export function generateAddonFromOpenAPI(
|
|
|
444
449
|
flags
|
|
445
450
|
)
|
|
446
451
|
|
|
452
|
+
if (flags.oauth || flags.credential === 'oauth2') {
|
|
453
|
+
files[`src/${name}.credential.ts`] = generateCredentialFile(spec, vars)
|
|
454
|
+
}
|
|
455
|
+
|
|
447
456
|
// Generate variable file for BASE_URL
|
|
448
457
|
files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars)
|
|
449
458
|
|
|
@@ -1075,9 +1084,7 @@ function generateUpstreamAuthFile(
|
|
|
1075
1084
|
)
|
|
1076
1085
|
lines.push('}')
|
|
1077
1086
|
lines.push('')
|
|
1078
|
-
lines.push(
|
|
1079
|
-
`export interface ${pascalName}UpstreamCredentials {`
|
|
1080
|
-
)
|
|
1087
|
+
lines.push(`export interface ${pascalName}UpstreamCredentials {`)
|
|
1081
1088
|
lines.push(' email?: string')
|
|
1082
1089
|
lines.push(' password?: string')
|
|
1083
1090
|
lines.push(' apiKey?: string')
|
|
@@ -1098,18 +1105,16 @@ function generateUpstreamAuthFile(
|
|
|
1098
1105
|
" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined"
|
|
1099
1106
|
)
|
|
1100
1107
|
lines.push('')
|
|
1101
|
-
lines.push(
|
|
1108
|
+
lines.push(
|
|
1109
|
+
'/** Decode a JWT payload without verifying — see file docblock. */'
|
|
1110
|
+
)
|
|
1102
1111
|
lines.push('const decodeJwtPayload = (token: string): unknown => {')
|
|
1103
1112
|
lines.push(" const part = token.split('.')[1]")
|
|
1104
1113
|
lines.push(' if (!part) return undefined')
|
|
1105
1114
|
lines.push(' try {')
|
|
1106
1115
|
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)")
|
|
1107
|
-
lines.push(
|
|
1108
|
-
|
|
1109
|
-
)
|
|
1110
|
-
lines.push(
|
|
1111
|
-
' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))'
|
|
1112
|
-
)
|
|
1116
|
+
lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))")
|
|
1117
|
+
lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))')
|
|
1113
1118
|
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))')
|
|
1114
1119
|
lines.push(' } catch {')
|
|
1115
1120
|
lines.push(' // Not a decodable JWT — caller treats it as missing claims.')
|
|
@@ -1156,7 +1161,9 @@ function generateUpstreamAuthFile(
|
|
|
1156
1161
|
lines.push(
|
|
1157
1162
|
` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`
|
|
1158
1163
|
)
|
|
1159
|
-
lines.push(
|
|
1164
|
+
lines.push(
|
|
1165
|
+
` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`
|
|
1166
|
+
)
|
|
1160
1167
|
lines.push(' headers,')
|
|
1161
1168
|
lines.push(
|
|
1162
1169
|
' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,'
|
|
@@ -1165,7 +1172,9 @@ function generateUpstreamAuthFile(
|
|
|
1165
1172
|
lines.push(' if (!response.ok) return null')
|
|
1166
1173
|
lines.push('')
|
|
1167
1174
|
lines.push(' const data: unknown = await response.json()')
|
|
1168
|
-
lines.push(
|
|
1175
|
+
lines.push(
|
|
1176
|
+
` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`
|
|
1177
|
+
)
|
|
1169
1178
|
lines.push(' if (!token) return null')
|
|
1170
1179
|
lines.push('')
|
|
1171
1180
|
if (claims.source === 'jwt') {
|
|
@@ -1178,7 +1187,9 @@ function generateUpstreamAuthFile(
|
|
|
1178
1187
|
lines.push(
|
|
1179
1188
|
` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`
|
|
1180
1189
|
)
|
|
1181
|
-
lines.push(
|
|
1190
|
+
lines.push(
|
|
1191
|
+
` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`
|
|
1192
|
+
)
|
|
1182
1193
|
lines.push(' if (!externalId || !email) return null')
|
|
1183
1194
|
lines.push('')
|
|
1184
1195
|
if (namePaths.length > 0) {
|
|
@@ -1254,10 +1265,8 @@ function generateServiceFile(
|
|
|
1254
1265
|
if (flags.credential && flags.credential !== 'oauth2') {
|
|
1255
1266
|
// Per-user credential: no special imports needed, creds passed via constructor
|
|
1256
1267
|
} else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
"import type { TypedSecretService } from '#pikku/secrets/pikku-secrets.gen.js'"
|
|
1260
|
-
)
|
|
1268
|
+
// OAuth2: the access token is resolved via the credential service and passed
|
|
1269
|
+
// via the constructor — no special imports needed.
|
|
1261
1270
|
} else if (flags.secret) {
|
|
1262
1271
|
lines.push(
|
|
1263
1272
|
`import type { ${pascalName}Secrets } from './${name}.secret.js'`
|
|
@@ -1275,29 +1284,6 @@ function generateServiceFile(
|
|
|
1275
1284
|
)
|
|
1276
1285
|
lines.push('')
|
|
1277
1286
|
|
|
1278
|
-
if (flags.oauth) {
|
|
1279
|
-
// Use OAuth2 details from spec if available
|
|
1280
|
-
const oauthScheme = Object.values(spec.securitySchemes).find(
|
|
1281
|
-
(s) => s.type === 'oauth2'
|
|
1282
|
-
)
|
|
1283
|
-
const authUrl =
|
|
1284
|
-
oauthScheme?.flows?.authorizationUrl ??
|
|
1285
|
-
'https://example.com/oauth2/authorize'
|
|
1286
|
-
const tokenUrl =
|
|
1287
|
-
oauthScheme?.flows?.tokenUrl ?? 'https://example.com/oauth2/token'
|
|
1288
|
-
const scopes = oauthScheme?.flows?.scopes
|
|
1289
|
-
? Object.keys(oauthScheme.flows.scopes)
|
|
1290
|
-
: ['read', 'write']
|
|
1291
|
-
|
|
1292
|
-
lines.push(`export const ${screamingName}_OAUTH2_CONFIG = {`)
|
|
1293
|
-
lines.push(` tokenSecretId: '${screamingName}_TOKENS',`)
|
|
1294
|
-
lines.push(` authorizationUrl: ${JSON.stringify(authUrl)},`)
|
|
1295
|
-
lines.push(` tokenUrl: ${JSON.stringify(tokenUrl)},`)
|
|
1296
|
-
lines.push(` scopes: ${JSON.stringify(scopes)},`)
|
|
1297
|
-
lines.push('}')
|
|
1298
|
-
lines.push('')
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
1287
|
// Generate route map from parsed operations
|
|
1302
1288
|
const routes: Record<string, RouteInfo> = {}
|
|
1303
1289
|
for (const { parsed } of opPairs) {
|
|
@@ -1359,19 +1345,13 @@ function generateServiceFile(
|
|
|
1359
1345
|
)
|
|
1360
1346
|
lines.push(' }')
|
|
1361
1347
|
} else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1362
|
-
lines.push(' private oauth: OAuth2Client')
|
|
1363
1348
|
lines.push('')
|
|
1364
1349
|
lines.push(
|
|
1365
|
-
` constructor(
|
|
1350
|
+
` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`
|
|
1366
1351
|
)
|
|
1367
1352
|
lines.push(
|
|
1368
1353
|
` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
|
|
1369
1354
|
)
|
|
1370
|
-
lines.push(' this.oauth = new OAuth2Client(')
|
|
1371
|
-
lines.push(` ${screamingName}_OAUTH2_CONFIG,`)
|
|
1372
|
-
lines.push(` '${screamingName}_APP_CREDENTIALS',`)
|
|
1373
|
-
lines.push(' secrets')
|
|
1374
|
-
lines.push(' )')
|
|
1375
1355
|
lines.push(' }')
|
|
1376
1356
|
} else if (flags.secret) {
|
|
1377
1357
|
lines.push('')
|
|
@@ -1399,9 +1379,7 @@ function generateServiceFile(
|
|
|
1399
1379
|
lines.push(' path: string,')
|
|
1400
1380
|
lines.push(' data?: unknown')
|
|
1401
1381
|
lines.push(' ): Promise<T> {')
|
|
1402
|
-
lines.push(
|
|
1403
|
-
' const input = data as Record<string, unknown> | undefined'
|
|
1404
|
-
)
|
|
1382
|
+
lines.push(' const input = data as Record<string, unknown> | undefined')
|
|
1405
1383
|
if (flags.camelCase) {
|
|
1406
1384
|
lines.push(' const rawData = input ? _toSnakeCase(input) : input')
|
|
1407
1385
|
}
|
|
@@ -1500,9 +1478,9 @@ function generateServiceFile(
|
|
|
1500
1478
|
lines.push(' body: body ? JSON.stringify(body) : undefined,')
|
|
1501
1479
|
lines.push(' })')
|
|
1502
1480
|
} else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1503
|
-
lines.push(
|
|
1504
|
-
|
|
1505
|
-
)
|
|
1481
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`')
|
|
1482
|
+
lines.push('')
|
|
1483
|
+
lines.push(' const response = await fetch(url.toString(), {')
|
|
1506
1484
|
lines.push(' method,')
|
|
1507
1485
|
lines.push(' headers,')
|
|
1508
1486
|
lines.push(' body: body ? JSON.stringify(body) : undefined,')
|
|
@@ -1581,6 +1559,79 @@ function generateServiceFile(
|
|
|
1581
1559
|
return lines.join('\n')
|
|
1582
1560
|
}
|
|
1583
1561
|
|
|
1562
|
+
function generateCredentialFile(spec: ParsedSpec, vars: AddonVars): string {
|
|
1563
|
+
const { camelName, screamingName } = vars
|
|
1564
|
+
const displayName = vars.displayName.replace(/'/g, '')
|
|
1565
|
+
const description = vars.description.replace(/'/g, '')
|
|
1566
|
+
|
|
1567
|
+
const oauthScheme = Object.values(spec.securitySchemes).find(
|
|
1568
|
+
(s) => s.type === 'oauth2'
|
|
1569
|
+
)
|
|
1570
|
+
const authorizationUrl = oauthScheme?.flows?.authorizationUrl
|
|
1571
|
+
const tokenUrl = oauthScheme?.flows?.tokenUrl
|
|
1572
|
+
const scopes = oauthScheme?.flows?.scopes
|
|
1573
|
+
? Object.keys(oauthScheme.flows.scopes)
|
|
1574
|
+
: ['read', 'write']
|
|
1575
|
+
|
|
1576
|
+
const lines: string[] = []
|
|
1577
|
+
lines.push("import { z } from 'zod'")
|
|
1578
|
+
lines.push("import { defineCredential } from '@pikku/core/credential'")
|
|
1579
|
+
lines.push("import { defineSecret } from '@pikku/core/secret'")
|
|
1580
|
+
lines.push('')
|
|
1581
|
+
lines.push(`export const ${camelName}TokenSchema = z.object({`)
|
|
1582
|
+
lines.push(' accessToken: z.string(),')
|
|
1583
|
+
lines.push(' refreshToken: z.string().optional(),')
|
|
1584
|
+
lines.push('})')
|
|
1585
|
+
lines.push('')
|
|
1586
|
+
lines.push(`export const ${camelName}OAuthAppSchema = z.object({`)
|
|
1587
|
+
lines.push(" clientId: z.string().describe('OAuth2 app client ID'),")
|
|
1588
|
+
lines.push(" clientSecret: z.string().describe('OAuth2 app client secret'),")
|
|
1589
|
+
lines.push('})')
|
|
1590
|
+
lines.push('')
|
|
1591
|
+
|
|
1592
|
+
if (!authorizationUrl || !tokenUrl) {
|
|
1593
|
+
lines.push(
|
|
1594
|
+
'// TODO: this spec declares no oauth2 flow URLs, so the placeholder(s)'
|
|
1595
|
+
)
|
|
1596
|
+
lines.push(
|
|
1597
|
+
`// below are not real. Replace them with ${displayName}'s endpoints —`
|
|
1598
|
+
)
|
|
1599
|
+
lines.push('// OAuth will fail against example.com.')
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// oauth2 must stay an inline object literal: the inspector reads it via AST
|
|
1603
|
+
// (add-credential.ts) and silently drops a config behind an identifier.
|
|
1604
|
+
lines.push('defineCredential({')
|
|
1605
|
+
lines.push(` name: '${camelName}',`)
|
|
1606
|
+
lines.push(` displayName: '${displayName}',`)
|
|
1607
|
+
lines.push(` description: '${description}',`)
|
|
1608
|
+
lines.push(` type: 'wire',`)
|
|
1609
|
+
lines.push(` schema: ${camelName}TokenSchema,`)
|
|
1610
|
+
lines.push(' oauth2: {')
|
|
1611
|
+
lines.push(` appCredentialSecretId: '${screamingName}_OAUTH_APP',`)
|
|
1612
|
+
lines.push(` tokenSecretId: '${screamingName}_OAUTH_TOKENS',`)
|
|
1613
|
+
lines.push(
|
|
1614
|
+
` authorizationUrl: ${JSON.stringify(authorizationUrl ?? 'https://example.com/oauth2/authorize')},`
|
|
1615
|
+
)
|
|
1616
|
+
lines.push(
|
|
1617
|
+
` tokenUrl: ${JSON.stringify(tokenUrl ?? 'https://example.com/oauth2/token')},`
|
|
1618
|
+
)
|
|
1619
|
+
lines.push(` scopes: ${JSON.stringify(scopes)},`)
|
|
1620
|
+
lines.push(' },')
|
|
1621
|
+
lines.push('})')
|
|
1622
|
+
lines.push('')
|
|
1623
|
+
lines.push('defineSecret({')
|
|
1624
|
+
lines.push(` name: '${camelName}OAuthApp',`)
|
|
1625
|
+
lines.push(` displayName: '${displayName} OAuth App',`)
|
|
1626
|
+
lines.push(` description: 'OAuth2 app credentials for ${displayName}',`)
|
|
1627
|
+
lines.push(` secretId: '${screamingName}_OAUTH_APP',`)
|
|
1628
|
+
lines.push(` schema: ${camelName}OAuthAppSchema,`)
|
|
1629
|
+
lines.push('})')
|
|
1630
|
+
lines.push('')
|
|
1631
|
+
|
|
1632
|
+
return lines.join('\n')
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1584
1635
|
function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
|
|
1585
1636
|
const { camelName, screamingName } = vars
|
|
1586
1637
|
const displayName = vars.displayName.replace(/'/g, '')
|
|
@@ -1589,7 +1640,7 @@ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
|
|
|
1589
1640
|
|
|
1590
1641
|
const lines: string[] = []
|
|
1591
1642
|
lines.push("import { z } from 'zod'")
|
|
1592
|
-
lines.push("import {
|
|
1643
|
+
lines.push("import { defineVariable } from '@pikku/core/variable'")
|
|
1593
1644
|
lines.push('')
|
|
1594
1645
|
|
|
1595
1646
|
const schemaVarName = `${camelName}BaseUrlSchema`
|
|
@@ -1606,7 +1657,7 @@ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
|
|
|
1606
1657
|
}
|
|
1607
1658
|
|
|
1608
1659
|
lines.push('')
|
|
1609
|
-
lines.push(`
|
|
1660
|
+
lines.push(`defineVariable({`)
|
|
1610
1661
|
lines.push(` name: '${screamingName}_BASE_URL',`)
|
|
1611
1662
|
lines.push(` displayName: '${displayName} Base URL',`)
|
|
1612
1663
|
lines.push(` description: 'The base URL for the ${displayName} API.',`)
|
package/src/naming.ts
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
* Uses operationId when available, otherwise generates from path segments.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
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
|
+
*/
|
|
7
16
|
const JS_RESERVED_WORDS = new Set([
|
|
8
|
-
// ECMAScript reserved words
|
|
9
|
-
'abstract',
|
|
10
17
|
'arguments',
|
|
11
18
|
'await',
|
|
12
|
-
'boolean',
|
|
13
19
|
'break',
|
|
14
|
-
'byte',
|
|
15
20
|
'case',
|
|
16
21
|
'catch',
|
|
17
|
-
'char',
|
|
18
22
|
'class',
|
|
19
23
|
'const',
|
|
20
24
|
'continue',
|
|
@@ -22,29 +26,22 @@ const JS_RESERVED_WORDS = new Set([
|
|
|
22
26
|
'default',
|
|
23
27
|
'delete',
|
|
24
28
|
'do',
|
|
25
|
-
'double',
|
|
26
29
|
'else',
|
|
27
30
|
'enum',
|
|
28
31
|
'eval',
|
|
29
32
|
'export',
|
|
30
33
|
'extends',
|
|
31
34
|
'false',
|
|
32
|
-
'final',
|
|
33
35
|
'finally',
|
|
34
|
-
'float',
|
|
35
36
|
'for',
|
|
36
37
|
'function',
|
|
37
|
-
'goto',
|
|
38
38
|
'if',
|
|
39
39
|
'implements',
|
|
40
40
|
'import',
|
|
41
41
|
'in',
|
|
42
42
|
'instanceof',
|
|
43
|
-
'int',
|
|
44
43
|
'interface',
|
|
45
44
|
'let',
|
|
46
|
-
'long',
|
|
47
|
-
'native',
|
|
48
45
|
'new',
|
|
49
46
|
'null',
|
|
50
47
|
'package',
|
|
@@ -52,40 +49,21 @@ const JS_RESERVED_WORDS = new Set([
|
|
|
52
49
|
'protected',
|
|
53
50
|
'public',
|
|
54
51
|
'return',
|
|
55
|
-
'short',
|
|
56
52
|
'static',
|
|
57
53
|
'super',
|
|
58
54
|
'switch',
|
|
59
|
-
'synchronized',
|
|
60
55
|
'this',
|
|
61
56
|
'throw',
|
|
62
57
|
'throws',
|
|
63
|
-
'transient',
|
|
64
58
|
'true',
|
|
65
59
|
'try',
|
|
66
60
|
'typeof',
|
|
67
61
|
'undefined',
|
|
68
62
|
'var',
|
|
69
63
|
'void',
|
|
70
|
-
'volatile',
|
|
71
64
|
'while',
|
|
72
65
|
'with',
|
|
73
66
|
'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
67
|
])
|
|
90
68
|
|
|
91
69
|
/**
|
|
@@ -234,6 +212,37 @@ interface OperationForNaming {
|
|
|
234
212
|
operationId?: string
|
|
235
213
|
}
|
|
236
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
|
+
|
|
237
246
|
export interface NamedOperation {
|
|
238
247
|
method: string
|
|
239
248
|
path: string
|
|
@@ -257,11 +266,16 @@ export function generateOperationNames(
|
|
|
257
266
|
// would produce files that collide on disk.
|
|
258
267
|
const usedNamesLower = new Map<string, number>()
|
|
259
268
|
|
|
269
|
+
const opIdPrefix = commonOperationIdPrefix(operations)
|
|
270
|
+
|
|
260
271
|
for (const op of operations) {
|
|
261
272
|
let name: string
|
|
262
273
|
|
|
263
274
|
if (op.operationId) {
|
|
264
|
-
|
|
275
|
+
const opId = opIdPrefix.length
|
|
276
|
+
? op.operationId.split('.').slice(opIdPrefix.length).join('.')
|
|
277
|
+
: op.operationId
|
|
278
|
+
name = fromOperationId(opId)
|
|
265
279
|
} else {
|
|
266
280
|
name = deriveNameFromPath(op.method, op.path, commonPrefix)
|
|
267
281
|
}
|