@pikku/openapi-parser 0.12.11 → 0.12.13
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 +12 -0
- package/dist/auth-config.d.ts +74 -0
- package/dist/auth-config.js +74 -0
- package/dist/codegen.d.ts +3 -0
- package/dist/codegen.js +215 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/auth-config.ts +87 -0
- package/src/codegen.test.ts +162 -0
- package/src/codegen.ts +269 -9
- package/src/index.ts +9 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @pikku/openapi-parser
|
|
2
2
|
|
|
3
|
+
## 0.12.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0f3edd3: Support an operator-supplied auth config that overrides/augments a spec's securitySchemes: custom auth header name/format (e.g. a raw token in `authentication:` instead of `Authorization: Bearer`), and a delegated-login descriptor (login path, credential fields, token dot-path, claims mapping from the decoded JWT payload or response body) that emits a self-contained `src/<name>-upstream-auth.ts` `authenticate<Name>Upstream()` for wiring into `@pikku/better-auth`'s `delegatedAuth()` plugin.
|
|
8
|
+
|
|
9
|
+
## 0.12.12
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- d97f2a1: Always emit a description for generated addon functions (and their MCP tools). When an OpenAPI operation omits both `description` and `summary` (common), the generator now synthesizes one — a humanized `operationId` (with the `Controller` segment stripped), else `METHOD /path` — instead of emitting none. This removes the "MCP tool is missing a description" warnings and makes `--mcp`-exposed tools usable.
|
|
14
|
+
|
|
3
15
|
## 0.12.11
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const DelegatedClaimsSchema: z.ZodObject<{
|
|
3
|
+
source: z.ZodEnum<{
|
|
4
|
+
jwt: "jwt";
|
|
5
|
+
response: "response";
|
|
6
|
+
}>;
|
|
7
|
+
externalId: z.ZodString;
|
|
8
|
+
email: z.ZodString;
|
|
9
|
+
name: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
10
|
+
role: z.ZodOptional<z.ZodString>;
|
|
11
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export declare const DelegatedLoginSchema: z.ZodObject<{
|
|
14
|
+
loginPath: z.ZodString;
|
|
15
|
+
loginMethod: z.ZodDefault<z.ZodString>;
|
|
16
|
+
credentials: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
17
|
+
email: "email";
|
|
18
|
+
password: "password";
|
|
19
|
+
apiKey: "apiKey";
|
|
20
|
+
}>>>;
|
|
21
|
+
apiKeyHeader: z.ZodDefault<z.ZodString>;
|
|
22
|
+
tokenPath: z.ZodString;
|
|
23
|
+
expiresAtPath: z.ZodOptional<z.ZodString>;
|
|
24
|
+
claims: z.ZodObject<{
|
|
25
|
+
source: z.ZodEnum<{
|
|
26
|
+
jwt: "jwt";
|
|
27
|
+
response: "response";
|
|
28
|
+
}>;
|
|
29
|
+
externalId: z.ZodString;
|
|
30
|
+
email: z.ZodString;
|
|
31
|
+
name: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
32
|
+
role: z.ZodOptional<z.ZodString>;
|
|
33
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
export declare const AuthConfigSchema: z.ZodObject<{
|
|
37
|
+
headerName: z.ZodOptional<z.ZodString>;
|
|
38
|
+
headerFormat: z.ZodOptional<z.ZodEnum<{
|
|
39
|
+
raw: "raw";
|
|
40
|
+
bearer: "bearer";
|
|
41
|
+
}>>;
|
|
42
|
+
delegated: z.ZodOptional<z.ZodObject<{
|
|
43
|
+
loginPath: z.ZodString;
|
|
44
|
+
loginMethod: z.ZodDefault<z.ZodString>;
|
|
45
|
+
credentials: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
46
|
+
email: "email";
|
|
47
|
+
password: "password";
|
|
48
|
+
apiKey: "apiKey";
|
|
49
|
+
}>>>;
|
|
50
|
+
apiKeyHeader: z.ZodDefault<z.ZodString>;
|
|
51
|
+
tokenPath: z.ZodString;
|
|
52
|
+
expiresAtPath: z.ZodOptional<z.ZodString>;
|
|
53
|
+
claims: z.ZodObject<{
|
|
54
|
+
source: z.ZodEnum<{
|
|
55
|
+
jwt: "jwt";
|
|
56
|
+
response: "response";
|
|
57
|
+
}>;
|
|
58
|
+
externalId: z.ZodString;
|
|
59
|
+
email: z.ZodString;
|
|
60
|
+
name: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
61
|
+
role: z.ZodOptional<z.ZodString>;
|
|
62
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
63
|
+
}, z.core.$strip>;
|
|
64
|
+
}, z.core.$strip>>;
|
|
65
|
+
}, z.core.$strip>;
|
|
66
|
+
export type DelegatedLoginConfig = z.infer<typeof DelegatedLoginSchema>;
|
|
67
|
+
export type AuthConfig = z.infer<typeof AuthConfigSchema>;
|
|
68
|
+
/** The effective value template for the auth header. */
|
|
69
|
+
export declare const authHeaderValue: (config: AuthConfig, tokenExpr: string) => {
|
|
70
|
+
header: string;
|
|
71
|
+
value: string;
|
|
72
|
+
};
|
|
73
|
+
/** Load and validate an auth-config JSON file; throws with the zod issue list on mismatch. */
|
|
74
|
+
export declare function loadAuthConfig(filePath: string): Promise<AuthConfig>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator/agent-supplied auth configuration that overrides or augments an
|
|
3
|
+
* OpenAPI spec's securitySchemes. Needed for real-world enterprise specs that
|
|
4
|
+
* carry no machine-readable auth at all (auth described only in prose), or
|
|
5
|
+
* that use a custom auth header the spec doesn't declare.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'fs/promises';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
export const DelegatedClaimsSchema = z.object({
|
|
10
|
+
/** Where identity claims live: the decoded login-JWT payload, or the login response body. */
|
|
11
|
+
source: z.enum(['jwt', 'response']),
|
|
12
|
+
/** Dot-path to the stable upstream user id (e.g. 'user._id'). */
|
|
13
|
+
externalId: z.string(),
|
|
14
|
+
email: z.string(),
|
|
15
|
+
/** One dot-path, or several joined with a space (e.g. first + last name). */
|
|
16
|
+
name: z.union([z.string(), z.array(z.string())]).optional(),
|
|
17
|
+
role: z.string().optional(),
|
|
18
|
+
tenantId: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
export const DelegatedLoginSchema = z.object({
|
|
21
|
+
/** Spec-relative path of the login operation (e.g. '/users/login'). */
|
|
22
|
+
loginPath: z.string(),
|
|
23
|
+
loginMethod: z.string().default('post'),
|
|
24
|
+
/** Which credential fields the login accepts. email/password go in the JSON body; apiKey in a header. */
|
|
25
|
+
credentials: z
|
|
26
|
+
.array(z.enum(['email', 'password', 'apiKey']))
|
|
27
|
+
.default(['email', 'password']),
|
|
28
|
+
/** Header carrying the apiKey when 'apiKey' is a supported credential. */
|
|
29
|
+
apiKeyHeader: z.string().default('x-api-key'),
|
|
30
|
+
/** Dot-path to the token in the login response (e.g. 'token'). */
|
|
31
|
+
tokenPath: z.string(),
|
|
32
|
+
/** Dot-path to an epoch-seconds expiry in the response; defaults to the JWT `exp` claim when source is jwt. */
|
|
33
|
+
expiresAtPath: z.string().optional(),
|
|
34
|
+
claims: DelegatedClaimsSchema,
|
|
35
|
+
});
|
|
36
|
+
export const AuthConfigSchema = z.object({
|
|
37
|
+
/** Auth header the API expects on authenticated calls (e.g. 'authentication'). Overrides the spec. */
|
|
38
|
+
headerName: z.string().optional(),
|
|
39
|
+
/** 'raw' sends the bare token; 'bearer' prefixes it. Default: bearer for Authorization, raw for custom headers. */
|
|
40
|
+
headerFormat: z.enum(['raw', 'bearer']).optional(),
|
|
41
|
+
/** Present when end-users sign in with their existing upstream credentials (delegated login). */
|
|
42
|
+
delegated: DelegatedLoginSchema.optional(),
|
|
43
|
+
});
|
|
44
|
+
/** The effective value template for the auth header. */
|
|
45
|
+
export const authHeaderValue = (config, tokenExpr) => {
|
|
46
|
+
const header = config.headerName ?? 'Authorization';
|
|
47
|
+
const format = config.headerFormat ??
|
|
48
|
+
(config.headerName && config.headerName !== 'Authorization'
|
|
49
|
+
? 'raw'
|
|
50
|
+
: 'bearer');
|
|
51
|
+
return {
|
|
52
|
+
header,
|
|
53
|
+
value: format === 'bearer' ? `\`Bearer \${${tokenExpr}}\`` : tokenExpr,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/** Load and validate an auth-config JSON file; throws with the zod issue list on mismatch. */
|
|
57
|
+
export async function loadAuthConfig(filePath) {
|
|
58
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`Auth config ${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
const result = AuthConfigSchema.safeParse(parsed);
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
const issues = result.error.issues
|
|
69
|
+
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
70
|
+
.join('\n');
|
|
71
|
+
throw new Error(`Auth config ${filePath} is invalid:\n${issues}`);
|
|
72
|
+
}
|
|
73
|
+
return result.data;
|
|
74
|
+
}
|
package/dist/codegen.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Returns Record<string, string> compatible with getAddonFiles output.
|
|
4
4
|
*/
|
|
5
5
|
import type { ParsedSpec } from './parse-openapi.js';
|
|
6
|
+
import { type AuthConfig } from './auth-config.js';
|
|
6
7
|
interface AddonVars {
|
|
7
8
|
name: string;
|
|
8
9
|
camelName: string;
|
|
@@ -17,6 +18,8 @@ interface CodegenFlags {
|
|
|
17
18
|
credential?: 'apikey' | 'bearer' | 'oauth2';
|
|
18
19
|
mcp?: boolean;
|
|
19
20
|
camelCase?: boolean;
|
|
21
|
+
/** Operator-supplied auth overrides (custom header, delegated login). */
|
|
22
|
+
authConfig?: AuthConfig;
|
|
20
23
|
}
|
|
21
24
|
export declare function generateAddonFromOpenAPI(spec: ParsedSpec, vars: AddonVars, flags: CodegenFlags): Record<string, string>;
|
|
22
25
|
export {};
|
package/dist/codegen.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { schemaToZod, schemaVarName, sanitizeTypeName, createContext, snakeToCamel, } from './zod-codegen.js';
|
|
2
2
|
import { generateOperationNames, detectCommonPrefix, } from './naming.js';
|
|
3
|
+
import { authHeaderValue } from './auth-config.js';
|
|
3
4
|
function safeKey(key) {
|
|
4
5
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
5
6
|
}
|
|
@@ -27,12 +28,41 @@ const STATUS_TO_ERROR = {
|
|
|
27
28
|
function capitalize(str) {
|
|
28
29
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Turn an operationId like `contactsControllerGetContacts` (or snake/kebab
|
|
33
|
+
* variants) into a readable phrase — split camel/snake/kebab into words, drop
|
|
34
|
+
* noise segments ("controller"), and capitalize. Returns undefined if nothing
|
|
35
|
+
* usable remains.
|
|
36
|
+
*/
|
|
37
|
+
function humanizeOperationId(operationId) {
|
|
38
|
+
if (!operationId)
|
|
39
|
+
return undefined;
|
|
40
|
+
const words = operationId
|
|
41
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
42
|
+
.replace(/[_-]+/g, ' ')
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
.map((w) => w.trim())
|
|
45
|
+
.filter((w) => w && w.toLowerCase() !== 'controller');
|
|
46
|
+
if (words.length === 0)
|
|
47
|
+
return undefined;
|
|
48
|
+
return capitalize(words.join(' ').toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A description for the generated function (and its MCP tool). Prefers the
|
|
52
|
+
* spec's `description`, then `summary`; when a spec omits both (common), a
|
|
53
|
+
* synthesized description is still emitted so no function — and no MCP tool —
|
|
54
|
+
* ships description-less: a humanized operationId, else "METHOD /path".
|
|
55
|
+
*/
|
|
30
56
|
function humanDescription(parsed) {
|
|
31
57
|
const description = parsed.description?.trim();
|
|
32
58
|
if (description && !GENERIC_SUMMARIES.has(description.toLowerCase())) {
|
|
33
59
|
return capitalize(description);
|
|
34
60
|
}
|
|
35
|
-
|
|
61
|
+
const summary = parsed.summary?.trim();
|
|
62
|
+
if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
|
|
63
|
+
return capitalize(summary);
|
|
64
|
+
}
|
|
65
|
+
return humanizeOperationId(parsed.operationId) ?? `${parsed.method.toUpperCase()} ${parsed.path}`;
|
|
36
66
|
}
|
|
37
67
|
function getErrorClassesForResponses(errorResponses) {
|
|
38
68
|
const classes = [];
|
|
@@ -254,8 +284,17 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
254
284
|
files[`src/functions/${named.functionName}.function.ts`] = funcCode;
|
|
255
285
|
functionExports.push(named.functionName);
|
|
256
286
|
}
|
|
287
|
+
// Delegated login: emit the upstream authenticate() implementation
|
|
288
|
+
const upstreamAuthExport = flags.authConfig?.delegated
|
|
289
|
+
? `authenticate${vars.pascalName}Upstream`
|
|
290
|
+
: undefined;
|
|
291
|
+
if (flags.authConfig?.delegated) {
|
|
292
|
+
files[`src/${name}-upstream-auth.ts`] = generateUpstreamAuthFile(vars, flags.authConfig);
|
|
293
|
+
}
|
|
257
294
|
// Generate index.ts with all exports
|
|
258
|
-
files['src/index.ts'] = generateIndexFile(functionExports
|
|
295
|
+
files['src/index.ts'] = generateIndexFile(functionExports, {
|
|
296
|
+
upstreamAuth: upstreamAuthExport ? { name, export: upstreamAuthExport } : undefined,
|
|
297
|
+
});
|
|
259
298
|
// Generate typed API service class with route map
|
|
260
299
|
files[`src/${name}-api.service.ts`] = generateServiceFile(spec, opPairs, vars, flags);
|
|
261
300
|
// Generate variable file for BASE_URL
|
|
@@ -720,11 +759,164 @@ function buildOutputSchema(schema, ctx) {
|
|
|
720
759
|
}
|
|
721
760
|
return schemaToZod(schema, ctx);
|
|
722
761
|
}
|
|
723
|
-
function generateIndexFile(functionExports) {
|
|
762
|
+
function generateIndexFile(functionExports, extras) {
|
|
724
763
|
const lines = [];
|
|
725
764
|
for (const name of functionExports) {
|
|
726
765
|
lines.push(`export { ${name} } from './functions/${name}.function.js'`);
|
|
727
766
|
}
|
|
767
|
+
if (extras?.upstreamAuth) {
|
|
768
|
+
lines.push(`export { ${extras.upstreamAuth.export} } from './${extras.upstreamAuth.name}-upstream-auth.js'`);
|
|
769
|
+
}
|
|
770
|
+
lines.push('');
|
|
771
|
+
return lines.join('\n');
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
|
|
775
|
+
* delegated login. It performs the upstream login call described by the auth
|
|
776
|
+
* config, extracts the token, reads identity claims from the decoded JWT
|
|
777
|
+
* payload (base64url, NOT signature-verified — the token was just received
|
|
778
|
+
* over TLS from the login we ourselves performed) or the response body, and
|
|
779
|
+
* returns an identity object structurally compatible with
|
|
780
|
+
* `@pikku/better-auth`'s `UpstreamIdentity` — without depending on it.
|
|
781
|
+
*/
|
|
782
|
+
function generateUpstreamAuthFile(vars, authConfig) {
|
|
783
|
+
const { pascalName } = vars;
|
|
784
|
+
const delegated = authConfig.delegated;
|
|
785
|
+
const claims = delegated.claims;
|
|
786
|
+
const namePaths = claims.name
|
|
787
|
+
? Array.isArray(claims.name)
|
|
788
|
+
? claims.name
|
|
789
|
+
: [claims.name]
|
|
790
|
+
: [];
|
|
791
|
+
const lines = [];
|
|
792
|
+
lines.push(`export interface ${pascalName}UpstreamIdentity {`);
|
|
793
|
+
lines.push(' externalId: string');
|
|
794
|
+
lines.push(' email: string');
|
|
795
|
+
lines.push(' name?: string');
|
|
796
|
+
lines.push(' role?: string');
|
|
797
|
+
lines.push(' tenantId?: string');
|
|
798
|
+
lines.push(' credential: { token: string; expiresAt?: number; tenantId?: string }');
|
|
799
|
+
lines.push('}');
|
|
800
|
+
lines.push('');
|
|
801
|
+
lines.push(`export interface ${pascalName}UpstreamCredentials {`);
|
|
802
|
+
lines.push(' email?: string');
|
|
803
|
+
lines.push(' password?: string');
|
|
804
|
+
lines.push(' apiKey?: string');
|
|
805
|
+
lines.push('}');
|
|
806
|
+
lines.push('');
|
|
807
|
+
lines.push('const pick = (obj: unknown, path: string): unknown =>');
|
|
808
|
+
lines.push(' path');
|
|
809
|
+
lines.push(" .split('.')");
|
|
810
|
+
lines.push(' .reduce<any>(');
|
|
811
|
+
lines.push(" (o, key) => (o && typeof o === 'object' ? o[key] : undefined),");
|
|
812
|
+
lines.push(' obj');
|
|
813
|
+
lines.push(' )');
|
|
814
|
+
lines.push('');
|
|
815
|
+
lines.push('const str = (value: unknown): string | undefined =>');
|
|
816
|
+
lines.push(" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined");
|
|
817
|
+
lines.push('');
|
|
818
|
+
lines.push('/** Decode a JWT payload without verifying — see file docblock. */');
|
|
819
|
+
lines.push('const decodeJwtPayload = (token: string): unknown => {');
|
|
820
|
+
lines.push(" const part = token.split('.')[1]");
|
|
821
|
+
lines.push(' if (!part) return undefined');
|
|
822
|
+
lines.push(' try {');
|
|
823
|
+
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)");
|
|
824
|
+
lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))");
|
|
825
|
+
lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))');
|
|
826
|
+
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))');
|
|
827
|
+
lines.push(' } catch {');
|
|
828
|
+
lines.push(' // Not a decodable JWT — caller treats it as missing claims.');
|
|
829
|
+
lines.push(' return undefined');
|
|
830
|
+
lines.push(' }');
|
|
831
|
+
lines.push('}');
|
|
832
|
+
lines.push('');
|
|
833
|
+
lines.push('/**');
|
|
834
|
+
lines.push(` * Verify credentials against the upstream ${vars.displayName} login and map`);
|
|
835
|
+
lines.push(' * its response onto an upstream identity. Returns null when the upstream');
|
|
836
|
+
lines.push(' * rejects the credentials; network errors propagate to the caller.');
|
|
837
|
+
lines.push(' */');
|
|
838
|
+
lines.push(`export const authenticate${pascalName}Upstream = async (`);
|
|
839
|
+
lines.push(` credentials: ${pascalName}UpstreamCredentials,`);
|
|
840
|
+
lines.push(' baseUrl: string');
|
|
841
|
+
lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`);
|
|
842
|
+
lines.push(" const headers: Record<string, string> = { 'Content-Type': 'application/json' }");
|
|
843
|
+
lines.push(' const body: Record<string, string> = {}');
|
|
844
|
+
if (delegated.credentials.includes('email')) {
|
|
845
|
+
lines.push(' if (credentials.email) body.email = credentials.email');
|
|
846
|
+
}
|
|
847
|
+
if (delegated.credentials.includes('password')) {
|
|
848
|
+
lines.push(' if (credentials.password) body.password = credentials.password');
|
|
849
|
+
}
|
|
850
|
+
if (delegated.credentials.includes('apiKey')) {
|
|
851
|
+
lines.push(` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`);
|
|
852
|
+
}
|
|
853
|
+
lines.push('');
|
|
854
|
+
lines.push(` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`);
|
|
855
|
+
lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`);
|
|
856
|
+
lines.push(' headers,');
|
|
857
|
+
lines.push(' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,');
|
|
858
|
+
lines.push(' })');
|
|
859
|
+
lines.push(' if (!response.ok) return null');
|
|
860
|
+
lines.push('');
|
|
861
|
+
lines.push(' const data: unknown = await response.json()');
|
|
862
|
+
lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`);
|
|
863
|
+
lines.push(' if (!token) return null');
|
|
864
|
+
lines.push('');
|
|
865
|
+
if (claims.source === 'jwt') {
|
|
866
|
+
lines.push(' const claims = decodeJwtPayload(token)');
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
lines.push(' const claims = data');
|
|
870
|
+
}
|
|
871
|
+
lines.push(' if (!claims) return null');
|
|
872
|
+
lines.push('');
|
|
873
|
+
lines.push(` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`);
|
|
874
|
+
lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`);
|
|
875
|
+
lines.push(' if (!externalId || !email) return null');
|
|
876
|
+
lines.push('');
|
|
877
|
+
if (namePaths.length > 0) {
|
|
878
|
+
const parts = namePaths
|
|
879
|
+
.map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
|
|
880
|
+
.join(', ');
|
|
881
|
+
lines.push(` const name = [${parts}]`);
|
|
882
|
+
lines.push(' .filter((part): part is string => Boolean(part))');
|
|
883
|
+
lines.push(" .join(' ') || undefined");
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
lines.push(' const name = undefined');
|
|
887
|
+
}
|
|
888
|
+
if (claims.role) {
|
|
889
|
+
lines.push(` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
lines.push(' const role = undefined');
|
|
893
|
+
}
|
|
894
|
+
if (claims.tenantId) {
|
|
895
|
+
lines.push(` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
lines.push(' const tenantId = undefined');
|
|
899
|
+
}
|
|
900
|
+
if (delegated.expiresAtPath) {
|
|
901
|
+
lines.push(` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`);
|
|
902
|
+
}
|
|
903
|
+
else if (claims.source === 'jwt') {
|
|
904
|
+
lines.push(" const expiresAtRaw = pick(claims, 'exp')");
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
lines.push(' const expiresAtRaw = undefined');
|
|
908
|
+
}
|
|
909
|
+
lines.push(" const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined");
|
|
910
|
+
lines.push('');
|
|
911
|
+
lines.push(' return {');
|
|
912
|
+
lines.push(' externalId,');
|
|
913
|
+
lines.push(' email,');
|
|
914
|
+
lines.push(' name,');
|
|
915
|
+
lines.push(' role,');
|
|
916
|
+
lines.push(' tenantId,');
|
|
917
|
+
lines.push(' credential: { token, expiresAt, tenantId },');
|
|
918
|
+
lines.push(' }');
|
|
919
|
+
lines.push('}');
|
|
728
920
|
lines.push('');
|
|
729
921
|
return lines.join('\n');
|
|
730
922
|
}
|
|
@@ -890,12 +1082,23 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
890
1082
|
if (flags.credential && flags.credential !== 'oauth2') {
|
|
891
1083
|
// Per-user credential: use creds from wire.getCredentials()
|
|
892
1084
|
if (flags.credential === 'bearer') {
|
|
893
|
-
|
|
1085
|
+
if (flags.authConfig?.headerName) {
|
|
1086
|
+
// Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
|
|
1087
|
+
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.token');
|
|
1088
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.token}`');
|
|
1092
|
+
}
|
|
894
1093
|
}
|
|
895
1094
|
else {
|
|
896
|
-
// apikey: check spec for custom header name
|
|
1095
|
+
// apikey: auth-config override wins, else check spec for custom header name
|
|
897
1096
|
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
898
|
-
if (
|
|
1097
|
+
if (flags.authConfig?.headerName) {
|
|
1098
|
+
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
|
|
1099
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1100
|
+
}
|
|
1101
|
+
else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
899
1102
|
lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
|
|
900
1103
|
}
|
|
901
1104
|
else {
|
|
@@ -917,9 +1120,13 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
917
1120
|
lines.push(' })');
|
|
918
1121
|
}
|
|
919
1122
|
else if (flags.secret) {
|
|
920
|
-
//
|
|
1123
|
+
// Auth-config override wins, else use apiKey details from spec if available
|
|
921
1124
|
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
922
|
-
if (
|
|
1125
|
+
if (flags.authConfig?.headerName) {
|
|
1126
|
+
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
|
|
1127
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1128
|
+
}
|
|
1129
|
+
else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
923
1130
|
lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
|
|
924
1131
|
}
|
|
925
1132
|
else {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseOpenAPISpec, computeContractHash, type ParsedSpec, type ParsedOperation, type ParsedParam, type ErrorResponse, type SecuritySchemeInfo, } from './parse-openapi.js';
|
|
2
2
|
export { generateAddonFromOpenAPI } from './codegen.js';
|
|
3
|
+
export { AuthConfigSchema, DelegatedLoginSchema, loadAuthConfig, authHeaderValue, type AuthConfig, type DelegatedLoginConfig, } from './auth-config.js';
|
|
3
4
|
export { generateOperationNames, detectCommonPrefix, type NamedOperation, } from './naming.js';
|
|
4
5
|
export { type OpenAPISchema, type ZodCodegenContext, createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseOpenAPISpec, computeContractHash, } from './parse-openapi.js';
|
|
2
2
|
export { generateAddonFromOpenAPI } from './codegen.js';
|
|
3
|
+
export { AuthConfigSchema, DelegatedLoginSchema, loadAuthConfig, authHeaderValue, } from './auth-config.js';
|
|
3
4
|
export { generateOperationNames, detectCommonPrefix, } from './naming.js';
|
|
4
5
|
export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator/agent-supplied auth configuration that overrides or augments an
|
|
3
|
+
* OpenAPI spec's securitySchemes. Needed for real-world enterprise specs that
|
|
4
|
+
* carry no machine-readable auth at all (auth described only in prose), or
|
|
5
|
+
* that use a custom auth header the spec doesn't declare.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'fs/promises'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
|
|
10
|
+
export const DelegatedClaimsSchema = z.object({
|
|
11
|
+
/** Where identity claims live: the decoded login-JWT payload, or the login response body. */
|
|
12
|
+
source: z.enum(['jwt', 'response']),
|
|
13
|
+
/** Dot-path to the stable upstream user id (e.g. 'user._id'). */
|
|
14
|
+
externalId: z.string(),
|
|
15
|
+
email: z.string(),
|
|
16
|
+
/** One dot-path, or several joined with a space (e.g. first + last name). */
|
|
17
|
+
name: z.union([z.string(), z.array(z.string())]).optional(),
|
|
18
|
+
role: z.string().optional(),
|
|
19
|
+
tenantId: z.string().optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const DelegatedLoginSchema = z.object({
|
|
23
|
+
/** Spec-relative path of the login operation (e.g. '/users/login'). */
|
|
24
|
+
loginPath: z.string(),
|
|
25
|
+
loginMethod: z.string().default('post'),
|
|
26
|
+
/** Which credential fields the login accepts. email/password go in the JSON body; apiKey in a header. */
|
|
27
|
+
credentials: z
|
|
28
|
+
.array(z.enum(['email', 'password', 'apiKey']))
|
|
29
|
+
.default(['email', 'password']),
|
|
30
|
+
/** Header carrying the apiKey when 'apiKey' is a supported credential. */
|
|
31
|
+
apiKeyHeader: z.string().default('x-api-key'),
|
|
32
|
+
/** Dot-path to the token in the login response (e.g. 'token'). */
|
|
33
|
+
tokenPath: z.string(),
|
|
34
|
+
/** Dot-path to an epoch-seconds expiry in the response; defaults to the JWT `exp` claim when source is jwt. */
|
|
35
|
+
expiresAtPath: z.string().optional(),
|
|
36
|
+
claims: DelegatedClaimsSchema,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const AuthConfigSchema = z.object({
|
|
40
|
+
/** Auth header the API expects on authenticated calls (e.g. 'authentication'). Overrides the spec. */
|
|
41
|
+
headerName: z.string().optional(),
|
|
42
|
+
/** 'raw' sends the bare token; 'bearer' prefixes it. Default: bearer for Authorization, raw for custom headers. */
|
|
43
|
+
headerFormat: z.enum(['raw', 'bearer']).optional(),
|
|
44
|
+
/** Present when end-users sign in with their existing upstream credentials (delegated login). */
|
|
45
|
+
delegated: DelegatedLoginSchema.optional(),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export type DelegatedLoginConfig = z.infer<typeof DelegatedLoginSchema>
|
|
49
|
+
export type AuthConfig = z.infer<typeof AuthConfigSchema>
|
|
50
|
+
|
|
51
|
+
/** The effective value template for the auth header. */
|
|
52
|
+
export const authHeaderValue = (
|
|
53
|
+
config: AuthConfig,
|
|
54
|
+
tokenExpr: string
|
|
55
|
+
): { header: string; value: string } => {
|
|
56
|
+
const header = config.headerName ?? 'Authorization'
|
|
57
|
+
const format =
|
|
58
|
+
config.headerFormat ??
|
|
59
|
+
(config.headerName && config.headerName !== 'Authorization'
|
|
60
|
+
? 'raw'
|
|
61
|
+
: 'bearer')
|
|
62
|
+
return {
|
|
63
|
+
header,
|
|
64
|
+
value: format === 'bearer' ? `\`Bearer \${${tokenExpr}}\`` : tokenExpr,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Load and validate an auth-config JSON file; throws with the zod issue list on mismatch. */
|
|
69
|
+
export async function loadAuthConfig(filePath: string): Promise<AuthConfig> {
|
|
70
|
+
const raw = await readFile(filePath, 'utf-8')
|
|
71
|
+
let parsed: unknown
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(raw)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Auth config ${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
const result = AuthConfigSchema.safeParse(parsed)
|
|
80
|
+
if (!result.success) {
|
|
81
|
+
const issues = result.error.issues
|
|
82
|
+
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
83
|
+
.join('\n')
|
|
84
|
+
throw new Error(`Auth config ${filePath} is invalid:\n${issues}`)
|
|
85
|
+
}
|
|
86
|
+
return result.data
|
|
87
|
+
}
|
package/src/codegen.test.ts
CHANGED
|
@@ -780,3 +780,165 @@ describe('camelCase flag', () => {
|
|
|
780
780
|
)
|
|
781
781
|
})
|
|
782
782
|
})
|
|
783
|
+
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
// Function/MCP-tool description synthesis (specs that omit descriptions)
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
describe('function description synthesis', () => {
|
|
788
|
+
test('synthesizes a description from operationId when the spec omits one', () => {
|
|
789
|
+
const spec = makeSpec({
|
|
790
|
+
operations: [
|
|
791
|
+
makeOp({
|
|
792
|
+
operationId: 'contactsControllerGetContacts',
|
|
793
|
+
method: 'get',
|
|
794
|
+
path: '/contacts',
|
|
795
|
+
}),
|
|
796
|
+
],
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
800
|
+
oauth: false,
|
|
801
|
+
secret: false,
|
|
802
|
+
mcp: true,
|
|
803
|
+
})
|
|
804
|
+
const funcFile = files['src/functions/contactsControllerGetContacts.function.ts']
|
|
805
|
+
assert.ok(funcFile, 'function file should exist')
|
|
806
|
+
assert.ok(
|
|
807
|
+
funcFile.includes('description: "Contacts get contacts"'),
|
|
808
|
+
'humanized operationId (Controller stripped) should be emitted as the description'
|
|
809
|
+
)
|
|
810
|
+
assert.ok(funcFile.includes('mcp: true'), 'mcp tool flag should be present')
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
test('falls back to "METHOD /path" when operationId is absent', () => {
|
|
814
|
+
const spec = makeSpec({
|
|
815
|
+
operations: [makeOp({ operationId: undefined, method: 'get', path: '/health' })],
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
819
|
+
oauth: false,
|
|
820
|
+
secret: false,
|
|
821
|
+
})
|
|
822
|
+
const key = Object.keys(files).find((k) => k.startsWith('src/functions/'))
|
|
823
|
+
assert.ok(key, 'a function file should be generated')
|
|
824
|
+
assert.ok(
|
|
825
|
+
files[key].includes('description: "GET /health"'),
|
|
826
|
+
'should fall back to METHOD path when nothing else is available'
|
|
827
|
+
)
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
test('prefers the spec description over synthesis', () => {
|
|
831
|
+
const spec = makeSpec({
|
|
832
|
+
operations: [
|
|
833
|
+
makeOp({ operationId: 'getPet', description: 'Fetch a single pet by id' }),
|
|
834
|
+
],
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
838
|
+
oauth: false,
|
|
839
|
+
secret: false,
|
|
840
|
+
})
|
|
841
|
+
const funcFile = files['src/functions/getPet.function.ts']
|
|
842
|
+
assert.ok(
|
|
843
|
+
funcFile.includes('description: "Fetch a single pet by id"'),
|
|
844
|
+
'the real spec description should win over the synthesized one'
|
|
845
|
+
)
|
|
846
|
+
})
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
// Auth-config: custom headers + delegated login
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
describe('auth config', () => {
|
|
853
|
+
const delegatedConfig = {
|
|
854
|
+
headerName: 'authentication',
|
|
855
|
+
headerFormat: 'raw' as const,
|
|
856
|
+
delegated: {
|
|
857
|
+
loginPath: '/users/login-ai-plugin',
|
|
858
|
+
loginMethod: 'post',
|
|
859
|
+
credentials: ['email', 'password'] as ('email' | 'password' | 'apiKey')[],
|
|
860
|
+
apiKeyHeader: 'x-api-key',
|
|
861
|
+
tokenPath: 'token',
|
|
862
|
+
claims: {
|
|
863
|
+
source: 'jwt' as const,
|
|
864
|
+
externalId: 'user._id',
|
|
865
|
+
email: 'user.email',
|
|
866
|
+
name: ['user.first_name', 'user.last_name'],
|
|
867
|
+
role: 'user.role',
|
|
868
|
+
tenantId: 'user.company',
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
test('custom raw header replaces Authorization Bearer in the bearer-credential service', () => {
|
|
874
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
875
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
876
|
+
oauth: false,
|
|
877
|
+
secret: false,
|
|
878
|
+
credential: 'bearer',
|
|
879
|
+
authConfig: delegatedConfig,
|
|
880
|
+
})
|
|
881
|
+
const service = files['src/test-api-api.service.ts']
|
|
882
|
+
assert.ok(
|
|
883
|
+
service.includes('headers["authentication"] = this.creds.token'),
|
|
884
|
+
'custom raw header should carry the bare token'
|
|
885
|
+
)
|
|
886
|
+
assert.ok(
|
|
887
|
+
!service.includes('headers.Authorization'),
|
|
888
|
+
'default Authorization header must not be emitted'
|
|
889
|
+
)
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
test('custom header with bearer format keeps the Bearer prefix', () => {
|
|
893
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
894
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
895
|
+
oauth: false,
|
|
896
|
+
secret: false,
|
|
897
|
+
credential: 'bearer',
|
|
898
|
+
authConfig: { headerName: 'x-auth', headerFormat: 'bearer' as const },
|
|
899
|
+
})
|
|
900
|
+
const service = files['src/test-api-api.service.ts']
|
|
901
|
+
assert.ok(
|
|
902
|
+
service.includes('headers["x-auth"] = `Bearer ${this.creds.token}`'),
|
|
903
|
+
'bearer format should keep the prefix on the custom header'
|
|
904
|
+
)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
test('delegated config emits the upstream-auth file and exports it', () => {
|
|
908
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
909
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
910
|
+
oauth: false,
|
|
911
|
+
secret: false,
|
|
912
|
+
credential: 'bearer',
|
|
913
|
+
authConfig: delegatedConfig,
|
|
914
|
+
})
|
|
915
|
+
const authFile = files['src/test-api-upstream-auth.ts']
|
|
916
|
+
assert.ok(authFile, 'upstream auth file should be generated')
|
|
917
|
+
assert.ok(authFile.includes('export const authenticateTestApiUpstream'))
|
|
918
|
+
assert.ok(
|
|
919
|
+
authFile.includes('/users/login-ai-plugin'),
|
|
920
|
+
'login path baked in'
|
|
921
|
+
)
|
|
922
|
+
assert.ok(authFile.includes('"user._id"'), 'externalId claim path baked in')
|
|
923
|
+
assert.ok(
|
|
924
|
+
authFile.includes("pick(claims, 'exp')"),
|
|
925
|
+
'jwt source defaults expiry to the exp claim'
|
|
926
|
+
)
|
|
927
|
+
assert.ok(
|
|
928
|
+
files['src/index.ts'].includes(
|
|
929
|
+
"export { authenticateTestApiUpstream } from './test-api-upstream-auth.js'"
|
|
930
|
+
),
|
|
931
|
+
'index should export the authenticate function'
|
|
932
|
+
)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
test('no delegated config → no upstream-auth file', () => {
|
|
936
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
937
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
938
|
+
oauth: false,
|
|
939
|
+
secret: false,
|
|
940
|
+
credential: 'bearer',
|
|
941
|
+
})
|
|
942
|
+
assert.equal(files['src/test-api-upstream-auth.ts'], undefined)
|
|
943
|
+
})
|
|
944
|
+
})
|
package/src/codegen.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
detectCommonPrefix,
|
|
21
21
|
type NamedOperation,
|
|
22
22
|
} from './naming.js'
|
|
23
|
+
import { authHeaderValue, type AuthConfig } from './auth-config.js'
|
|
23
24
|
|
|
24
25
|
interface AddonVars {
|
|
25
26
|
name: string
|
|
@@ -36,6 +37,8 @@ interface CodegenFlags {
|
|
|
36
37
|
credential?: 'apikey' | 'bearer' | 'oauth2'
|
|
37
38
|
mcp?: boolean
|
|
38
39
|
camelCase?: boolean
|
|
40
|
+
/** Operator-supplied auth overrides (custom header, delegated login). */
|
|
41
|
+
authConfig?: AuthConfig
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
function safeKey(key: string): string {
|
|
@@ -69,12 +72,40 @@ function capitalize(str: string): string {
|
|
|
69
72
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Turn an operationId like `contactsControllerGetContacts` (or snake/kebab
|
|
77
|
+
* variants) into a readable phrase — split camel/snake/kebab into words, drop
|
|
78
|
+
* noise segments ("controller"), and capitalize. Returns undefined if nothing
|
|
79
|
+
* usable remains.
|
|
80
|
+
*/
|
|
81
|
+
function humanizeOperationId(operationId?: string): string | undefined {
|
|
82
|
+
if (!operationId) return undefined
|
|
83
|
+
const words = operationId
|
|
84
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
85
|
+
.replace(/[_-]+/g, ' ')
|
|
86
|
+
.split(/\s+/)
|
|
87
|
+
.map((w) => w.trim())
|
|
88
|
+
.filter((w) => w && w.toLowerCase() !== 'controller')
|
|
89
|
+
if (words.length === 0) return undefined
|
|
90
|
+
return capitalize(words.join(' ').toLowerCase())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A description for the generated function (and its MCP tool). Prefers the
|
|
95
|
+
* spec's `description`, then `summary`; when a spec omits both (common), a
|
|
96
|
+
* synthesized description is still emitted so no function — and no MCP tool —
|
|
97
|
+
* ships description-less: a humanized operationId, else "METHOD /path".
|
|
98
|
+
*/
|
|
99
|
+
function humanDescription(parsed: ParsedOperation): string {
|
|
73
100
|
const description = parsed.description?.trim()
|
|
74
101
|
if (description && !GENERIC_SUMMARIES.has(description.toLowerCase())) {
|
|
75
102
|
return capitalize(description)
|
|
76
103
|
}
|
|
77
|
-
|
|
104
|
+
const summary = parsed.summary?.trim()
|
|
105
|
+
if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
|
|
106
|
+
return capitalize(summary)
|
|
107
|
+
}
|
|
108
|
+
return humanizeOperationId(parsed.operationId) ?? `${parsed.method.toUpperCase()} ${parsed.path}`
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
function getErrorClassesForResponses(
|
|
@@ -389,8 +420,21 @@ export function generateAddonFromOpenAPI(
|
|
|
389
420
|
functionExports.push(named.functionName)
|
|
390
421
|
}
|
|
391
422
|
|
|
423
|
+
// Delegated login: emit the upstream authenticate() implementation
|
|
424
|
+
const upstreamAuthExport = flags.authConfig?.delegated
|
|
425
|
+
? `authenticate${vars.pascalName}Upstream`
|
|
426
|
+
: undefined
|
|
427
|
+
if (flags.authConfig?.delegated) {
|
|
428
|
+
files[`src/${name}-upstream-auth.ts`] = generateUpstreamAuthFile(
|
|
429
|
+
vars,
|
|
430
|
+
flags.authConfig
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
392
434
|
// Generate index.ts with all exports
|
|
393
|
-
files['src/index.ts'] = generateIndexFile(functionExports
|
|
435
|
+
files['src/index.ts'] = generateIndexFile(functionExports, {
|
|
436
|
+
upstreamAuth: upstreamAuthExport ? { name, export: upstreamAuthExport } : undefined,
|
|
437
|
+
})
|
|
394
438
|
|
|
395
439
|
// Generate typed API service class with route map
|
|
396
440
|
files[`src/${name}-api.service.ts`] = generateServiceFile(
|
|
@@ -980,11 +1024,206 @@ function buildOutputSchema(schema: any, ctx: ZodCodegenContext): string {
|
|
|
980
1024
|
return schemaToZod(schema, ctx)
|
|
981
1025
|
}
|
|
982
1026
|
|
|
983
|
-
function generateIndexFile(
|
|
1027
|
+
function generateIndexFile(
|
|
1028
|
+
functionExports: string[],
|
|
1029
|
+
extras?: { upstreamAuth?: { name: string; export: string } }
|
|
1030
|
+
): string {
|
|
984
1031
|
const lines: string[] = []
|
|
985
1032
|
for (const name of functionExports) {
|
|
986
1033
|
lines.push(`export { ${name} } from './functions/${name}.function.js'`)
|
|
987
1034
|
}
|
|
1035
|
+
if (extras?.upstreamAuth) {
|
|
1036
|
+
lines.push(
|
|
1037
|
+
`export { ${extras.upstreamAuth.export} } from './${extras.upstreamAuth.name}-upstream-auth.js'`
|
|
1038
|
+
)
|
|
1039
|
+
}
|
|
1040
|
+
lines.push('')
|
|
1041
|
+
return lines.join('\n')
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
|
|
1046
|
+
* delegated login. It performs the upstream login call described by the auth
|
|
1047
|
+
* config, extracts the token, reads identity claims from the decoded JWT
|
|
1048
|
+
* payload (base64url, NOT signature-verified — the token was just received
|
|
1049
|
+
* over TLS from the login we ourselves performed) or the response body, and
|
|
1050
|
+
* returns an identity object structurally compatible with
|
|
1051
|
+
* `@pikku/better-auth`'s `UpstreamIdentity` — without depending on it.
|
|
1052
|
+
*/
|
|
1053
|
+
function generateUpstreamAuthFile(
|
|
1054
|
+
vars: AddonVars,
|
|
1055
|
+
authConfig: AuthConfig
|
|
1056
|
+
): string {
|
|
1057
|
+
const { pascalName } = vars
|
|
1058
|
+
const delegated = authConfig.delegated!
|
|
1059
|
+
const claims = delegated.claims
|
|
1060
|
+
const namePaths = claims.name
|
|
1061
|
+
? Array.isArray(claims.name)
|
|
1062
|
+
? claims.name
|
|
1063
|
+
: [claims.name]
|
|
1064
|
+
: []
|
|
1065
|
+
|
|
1066
|
+
const lines: string[] = []
|
|
1067
|
+
lines.push(`export interface ${pascalName}UpstreamIdentity {`)
|
|
1068
|
+
lines.push(' externalId: string')
|
|
1069
|
+
lines.push(' email: string')
|
|
1070
|
+
lines.push(' name?: string')
|
|
1071
|
+
lines.push(' role?: string')
|
|
1072
|
+
lines.push(' tenantId?: string')
|
|
1073
|
+
lines.push(
|
|
1074
|
+
' credential: { token: string; expiresAt?: number; tenantId?: string }'
|
|
1075
|
+
)
|
|
1076
|
+
lines.push('}')
|
|
1077
|
+
lines.push('')
|
|
1078
|
+
lines.push(
|
|
1079
|
+
`export interface ${pascalName}UpstreamCredentials {`
|
|
1080
|
+
)
|
|
1081
|
+
lines.push(' email?: string')
|
|
1082
|
+
lines.push(' password?: string')
|
|
1083
|
+
lines.push(' apiKey?: string')
|
|
1084
|
+
lines.push('}')
|
|
1085
|
+
lines.push('')
|
|
1086
|
+
lines.push('const pick = (obj: unknown, path: string): unknown =>')
|
|
1087
|
+
lines.push(' path')
|
|
1088
|
+
lines.push(" .split('.')")
|
|
1089
|
+
lines.push(' .reduce<any>(')
|
|
1090
|
+
lines.push(
|
|
1091
|
+
" (o, key) => (o && typeof o === 'object' ? o[key] : undefined),"
|
|
1092
|
+
)
|
|
1093
|
+
lines.push(' obj')
|
|
1094
|
+
lines.push(' )')
|
|
1095
|
+
lines.push('')
|
|
1096
|
+
lines.push('const str = (value: unknown): string | undefined =>')
|
|
1097
|
+
lines.push(
|
|
1098
|
+
" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined"
|
|
1099
|
+
)
|
|
1100
|
+
lines.push('')
|
|
1101
|
+
lines.push('/** Decode a JWT payload without verifying — see file docblock. */')
|
|
1102
|
+
lines.push('const decodeJwtPayload = (token: string): unknown => {')
|
|
1103
|
+
lines.push(" const part = token.split('.')[1]")
|
|
1104
|
+
lines.push(' if (!part) return undefined')
|
|
1105
|
+
lines.push(' try {')
|
|
1106
|
+
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)")
|
|
1107
|
+
lines.push(
|
|
1108
|
+
" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))"
|
|
1109
|
+
)
|
|
1110
|
+
lines.push(
|
|
1111
|
+
' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))'
|
|
1112
|
+
)
|
|
1113
|
+
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))')
|
|
1114
|
+
lines.push(' } catch {')
|
|
1115
|
+
lines.push(' // Not a decodable JWT — caller treats it as missing claims.')
|
|
1116
|
+
lines.push(' return undefined')
|
|
1117
|
+
lines.push(' }')
|
|
1118
|
+
lines.push('}')
|
|
1119
|
+
lines.push('')
|
|
1120
|
+
lines.push('/**')
|
|
1121
|
+
lines.push(
|
|
1122
|
+
` * Verify credentials against the upstream ${vars.displayName} login and map`
|
|
1123
|
+
)
|
|
1124
|
+
lines.push(
|
|
1125
|
+
' * its response onto an upstream identity. Returns null when the upstream'
|
|
1126
|
+
)
|
|
1127
|
+
lines.push(
|
|
1128
|
+
' * rejects the credentials; network errors propagate to the caller.'
|
|
1129
|
+
)
|
|
1130
|
+
lines.push(' */')
|
|
1131
|
+
lines.push(`export const authenticate${pascalName}Upstream = async (`)
|
|
1132
|
+
lines.push(` credentials: ${pascalName}UpstreamCredentials,`)
|
|
1133
|
+
lines.push(' baseUrl: string')
|
|
1134
|
+
lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`)
|
|
1135
|
+
lines.push(
|
|
1136
|
+
" const headers: Record<string, string> = { 'Content-Type': 'application/json' }"
|
|
1137
|
+
)
|
|
1138
|
+
lines.push(' const body: Record<string, string> = {}')
|
|
1139
|
+
if (delegated.credentials.includes('email')) {
|
|
1140
|
+
lines.push(' if (credentials.email) body.email = credentials.email')
|
|
1141
|
+
}
|
|
1142
|
+
if (delegated.credentials.includes('password')) {
|
|
1143
|
+
lines.push(
|
|
1144
|
+
' if (credentials.password) body.password = credentials.password'
|
|
1145
|
+
)
|
|
1146
|
+
}
|
|
1147
|
+
if (delegated.credentials.includes('apiKey')) {
|
|
1148
|
+
lines.push(
|
|
1149
|
+
` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`
|
|
1150
|
+
)
|
|
1151
|
+
}
|
|
1152
|
+
lines.push('')
|
|
1153
|
+
lines.push(
|
|
1154
|
+
` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`
|
|
1155
|
+
)
|
|
1156
|
+
lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`)
|
|
1157
|
+
lines.push(' headers,')
|
|
1158
|
+
lines.push(
|
|
1159
|
+
' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,'
|
|
1160
|
+
)
|
|
1161
|
+
lines.push(' })')
|
|
1162
|
+
lines.push(' if (!response.ok) return null')
|
|
1163
|
+
lines.push('')
|
|
1164
|
+
lines.push(' const data: unknown = await response.json()')
|
|
1165
|
+
lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`)
|
|
1166
|
+
lines.push(' if (!token) return null')
|
|
1167
|
+
lines.push('')
|
|
1168
|
+
if (claims.source === 'jwt') {
|
|
1169
|
+
lines.push(' const claims = decodeJwtPayload(token)')
|
|
1170
|
+
} else {
|
|
1171
|
+
lines.push(' const claims = data')
|
|
1172
|
+
}
|
|
1173
|
+
lines.push(' if (!claims) return null')
|
|
1174
|
+
lines.push('')
|
|
1175
|
+
lines.push(
|
|
1176
|
+
` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`
|
|
1177
|
+
)
|
|
1178
|
+
lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`)
|
|
1179
|
+
lines.push(' if (!externalId || !email) return null')
|
|
1180
|
+
lines.push('')
|
|
1181
|
+
if (namePaths.length > 0) {
|
|
1182
|
+
const parts = namePaths
|
|
1183
|
+
.map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
|
|
1184
|
+
.join(', ')
|
|
1185
|
+
lines.push(` const name = [${parts}]`)
|
|
1186
|
+
lines.push(' .filter((part): part is string => Boolean(part))')
|
|
1187
|
+
lines.push(" .join(' ') || undefined")
|
|
1188
|
+
} else {
|
|
1189
|
+
lines.push(' const name = undefined')
|
|
1190
|
+
}
|
|
1191
|
+
if (claims.role) {
|
|
1192
|
+
lines.push(
|
|
1193
|
+
` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`
|
|
1194
|
+
)
|
|
1195
|
+
} else {
|
|
1196
|
+
lines.push(' const role = undefined')
|
|
1197
|
+
}
|
|
1198
|
+
if (claims.tenantId) {
|
|
1199
|
+
lines.push(
|
|
1200
|
+
` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`
|
|
1201
|
+
)
|
|
1202
|
+
} else {
|
|
1203
|
+
lines.push(' const tenantId = undefined')
|
|
1204
|
+
}
|
|
1205
|
+
if (delegated.expiresAtPath) {
|
|
1206
|
+
lines.push(
|
|
1207
|
+
` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`
|
|
1208
|
+
)
|
|
1209
|
+
} else if (claims.source === 'jwt') {
|
|
1210
|
+
lines.push(" const expiresAtRaw = pick(claims, 'exp')")
|
|
1211
|
+
} else {
|
|
1212
|
+
lines.push(' const expiresAtRaw = undefined')
|
|
1213
|
+
}
|
|
1214
|
+
lines.push(
|
|
1215
|
+
" const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined"
|
|
1216
|
+
)
|
|
1217
|
+
lines.push('')
|
|
1218
|
+
lines.push(' return {')
|
|
1219
|
+
lines.push(' externalId,')
|
|
1220
|
+
lines.push(' email,')
|
|
1221
|
+
lines.push(' name,')
|
|
1222
|
+
lines.push(' role,')
|
|
1223
|
+
lines.push(' tenantId,')
|
|
1224
|
+
lines.push(' credential: { token, expiresAt, tenantId },')
|
|
1225
|
+
lines.push(' }')
|
|
1226
|
+
lines.push('}')
|
|
988
1227
|
lines.push('')
|
|
989
1228
|
return lines.join('\n')
|
|
990
1229
|
}
|
|
@@ -1214,13 +1453,28 @@ function generateServiceFile(
|
|
|
1214
1453
|
if (flags.credential && flags.credential !== 'oauth2') {
|
|
1215
1454
|
// Per-user credential: use creds from wire.getCredentials()
|
|
1216
1455
|
if (flags.credential === 'bearer') {
|
|
1217
|
-
|
|
1456
|
+
if (flags.authConfig?.headerName) {
|
|
1457
|
+
// Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
|
|
1458
|
+
const { header, value } = authHeaderValue(
|
|
1459
|
+
flags.authConfig,
|
|
1460
|
+
'this.creds.token'
|
|
1461
|
+
)
|
|
1462
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
|
|
1463
|
+
} else {
|
|
1464
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.token}`')
|
|
1465
|
+
}
|
|
1218
1466
|
} else {
|
|
1219
|
-
// apikey: check spec for custom header name
|
|
1467
|
+
// apikey: auth-config override wins, else check spec for custom header name
|
|
1220
1468
|
const apiKeyScheme = Object.values(spec.securitySchemes).find(
|
|
1221
1469
|
(s) => s.type === 'apiKey'
|
|
1222
1470
|
)
|
|
1223
|
-
if (
|
|
1471
|
+
if (flags.authConfig?.headerName) {
|
|
1472
|
+
const { header, value } = authHeaderValue(
|
|
1473
|
+
flags.authConfig,
|
|
1474
|
+
'this.creds.apiKey'
|
|
1475
|
+
)
|
|
1476
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
|
|
1477
|
+
} else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
1224
1478
|
lines.push(
|
|
1225
1479
|
` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
|
|
1226
1480
|
)
|
|
@@ -1243,11 +1497,17 @@ function generateServiceFile(
|
|
|
1243
1497
|
lines.push(' body: body ? JSON.stringify(body) : undefined,')
|
|
1244
1498
|
lines.push(' })')
|
|
1245
1499
|
} else if (flags.secret) {
|
|
1246
|
-
//
|
|
1500
|
+
// Auth-config override wins, else use apiKey details from spec if available
|
|
1247
1501
|
const apiKeyScheme = Object.values(spec.securitySchemes).find(
|
|
1248
1502
|
(s) => s.type === 'apiKey'
|
|
1249
1503
|
)
|
|
1250
|
-
if (
|
|
1504
|
+
if (flags.authConfig?.headerName) {
|
|
1505
|
+
const { header, value } = authHeaderValue(
|
|
1506
|
+
flags.authConfig,
|
|
1507
|
+
'this.creds.apiKey'
|
|
1508
|
+
)
|
|
1509
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
|
|
1510
|
+
} else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
1251
1511
|
lines.push(
|
|
1252
1512
|
` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
|
|
1253
1513
|
)
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,15 @@ export {
|
|
|
10
10
|
|
|
11
11
|
export { generateAddonFromOpenAPI } from './codegen.js'
|
|
12
12
|
|
|
13
|
+
export {
|
|
14
|
+
AuthConfigSchema,
|
|
15
|
+
DelegatedLoginSchema,
|
|
16
|
+
loadAuthConfig,
|
|
17
|
+
authHeaderValue,
|
|
18
|
+
type AuthConfig,
|
|
19
|
+
type DelegatedLoginConfig,
|
|
20
|
+
} from './auth-config.js'
|
|
21
|
+
|
|
13
22
|
export {
|
|
14
23
|
generateOperationNames,
|
|
15
24
|
detectCommonPrefix,
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/codegen.ts","./src/index.ts","./src/naming.ts","./src/openapi-to-zod-schema.ts","./src/parse-openapi.ts","./src/zod-codegen.ts"],"version":"6.0.3"}
|
|
1
|
+
{"root":["./src/auth-config.ts","./src/codegen.ts","./src/index.ts","./src/naming.ts","./src/openapi-to-zod-schema.ts","./src/parse-openapi.ts","./src/zod-codegen.ts"],"version":"6.0.3"}
|