@pikku/openapi-parser 0.12.15 → 0.12.16
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 +17 -0
- package/dist/codegen.js +71 -29
- package/dist/naming.js +44 -33
- package/package.json +1 -1
- package/src/codegen.test.ts +9 -3
- package/src/codegen.ts +106 -55
- package/src/naming.ts +47 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @pikku/openapi-parser
|
|
2
2
|
|
|
3
|
+
## 0.12.16
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9f0d0eb: Migrate the `--oauth` addon scaffold off `OAuth2Client`. A scaffolded OAuth2
|
|
8
|
+
addon service used to construct `new OAuth2Client(config, appCredentialSecretId,
|
|
9
|
+
secrets)` and do its own token exchange/refresh — the responsibility better-auth
|
|
10
|
+
now owns via the credential service. The `pikku new addon --oauth` scaffold (and
|
|
11
|
+
the OpenAPI `--openapi` generator) now emit a service that receives a ready
|
|
12
|
+
access token: `services.ts` uses `createWireServices` + `wire.getCredential<{
|
|
13
|
+
accessToken: string }>(name)` and the service does a plain `fetch` with
|
|
14
|
+
`Authorization: Bearer ${accessToken}`, matching the existing per-user
|
|
15
|
+
bearer/apikey credential scaffold. With no remaining consumers, `OAuth2Client`
|
|
16
|
+
(`@pikku/core/oauth2`) and its test are removed; the `./oauth2` export keeps the
|
|
17
|
+
`OAuth2AppCredential` / `OAuth2Token` types.
|
|
18
|
+
- 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`.
|
|
19
|
+
|
|
3
20
|
## 0.12.15
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
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 { wireCredential } from '@pikku/core/credential'");
|
|
1188
|
+
lines.push("import { wireSecret } 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('wireCredential({');
|
|
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('wireSecret({');
|
|
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, '');
|
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 { wireCredential } from '@pikku/core/credential'")
|
|
1579
|
+
lines.push("import { wireSecret } 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('wireCredential({')
|
|
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('wireSecret({')
|
|
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, '')
|
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
|
}
|