@pikku/openapi-parser 0.12.21 → 0.12.23
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 +28 -0
- package/dist/auth-config.d.ts +53 -16
- package/dist/auth-config.js +43 -9
- package/dist/codegen.d.ts +1 -1
- package/dist/codegen.js +445 -395
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/parse-openapi.d.ts +31 -5
- package/dist/parse-openapi.js +198 -70
- package/package.json +1 -1
package/dist/codegen.js
CHANGED
|
@@ -1,6 +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
|
+
import { authHeaderValue, DelegatedLoginSchema, } from './auth-config.js';
|
|
4
4
|
function safeKey(key) {
|
|
5
5
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
6
6
|
}
|
|
@@ -25,6 +25,7 @@ const STATUS_TO_ERROR = {
|
|
|
25
25
|
429: 'TooManyRequestsError',
|
|
26
26
|
500: 'InternalServerError',
|
|
27
27
|
};
|
|
28
|
+
const inTemplate = (value) => value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
28
29
|
function capitalize(str) {
|
|
29
30
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
30
31
|
}
|
|
@@ -281,8 +282,11 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
281
282
|
camelCase: flags.camelCase,
|
|
282
283
|
});
|
|
283
284
|
const inlineSchemas = singleSchemasPerOp.get(i) ?? new Set();
|
|
284
|
-
const
|
|
285
|
-
files[`src/functions/${named.functionName}.function.ts`] =
|
|
285
|
+
const { functionFile, schemasFile } = generateFunctionFile(named, parsed, vars, funcCtx, spec, flags, inlineSchemas, sharedWithDeps, schemaIdentityMap);
|
|
286
|
+
files[`src/functions/${named.functionName}.function.ts`] = functionFile;
|
|
287
|
+
if (schemasFile) {
|
|
288
|
+
files[`src/functions/${named.functionName}.schemas.ts`] = schemasFile;
|
|
289
|
+
}
|
|
286
290
|
functionExports.push(named.functionName);
|
|
287
291
|
}
|
|
288
292
|
// Delegated login: emit the upstream authenticate() implementation
|
|
@@ -489,9 +493,9 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
489
493
|
if (hasInput) {
|
|
490
494
|
inputCode = buildInputSchema(parsed, ctx);
|
|
491
495
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
496
|
+
outputCode = isVagueResponse(parsed)
|
|
497
|
+
? 'z.unknown()'
|
|
498
|
+
: buildOutputSchema(parsed.responseSchema, ctx);
|
|
495
499
|
// Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
|
|
496
500
|
// can't infer the type (TS7056). Replace with z.any() to stay compilable.
|
|
497
501
|
const LINE_LIMIT = 500;
|
|
@@ -516,14 +520,19 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
516
520
|
}
|
|
517
521
|
// Determine error imports needed
|
|
518
522
|
const errorClasses = getErrorClassesForResponses(parsed.errorResponses);
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
lines.push("import { pikkuSessionlessFunc } from '#pikku/function'");
|
|
523
|
+
const schemaExports = [
|
|
524
|
+
...(hasInput && inputCode ? [inputName] : []),
|
|
525
|
+
...(outputCode ? [outputName] : []),
|
|
526
|
+
];
|
|
527
|
+
lines.push("import { pikkuSessionlessFunc } from '#pikku/addon/function'");
|
|
524
528
|
if (errorClasses.length > 0) {
|
|
525
529
|
lines.push(`import { ${errorClasses.join(', ')} } from '@pikku/core/errors'`);
|
|
526
530
|
}
|
|
531
|
+
if (schemaExports.length > 0) {
|
|
532
|
+
lines.push(`import { ${schemaExports.join(', ')} } from './${named.functionName}.schemas.js'`);
|
|
533
|
+
}
|
|
534
|
+
lines.push('');
|
|
535
|
+
const schemaLines = ["import { z } from 'zod'"];
|
|
527
536
|
// Import referenced component schemas from the types file.
|
|
528
537
|
// Only import schemas that are in the shared set (not inlined ones).
|
|
529
538
|
// In addition to the refs tracked through usedRefs, scan the generated Zod
|
|
@@ -543,10 +552,10 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
543
552
|
.filter(Boolean)),
|
|
544
553
|
].sort();
|
|
545
554
|
if (schemaImports.length > 0) {
|
|
546
|
-
|
|
555
|
+
schemaLines.push(`import { ${schemaImports.join(', ')} } from '../${name}.types.js'`);
|
|
547
556
|
}
|
|
548
557
|
}
|
|
549
|
-
|
|
558
|
+
schemaLines.push('');
|
|
550
559
|
// Emit inline (single-use) schema definitions before input/output
|
|
551
560
|
if (inlineSchemas.size > 0) {
|
|
552
561
|
// Build a mini component schemas map for just the inline schemas
|
|
@@ -607,7 +616,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
607
616
|
const emittedTypeNames = new Set();
|
|
608
617
|
// Forward declarations for cycles and forward references within inline schemas
|
|
609
618
|
if (forwardRefs.size > 0) {
|
|
610
|
-
|
|
619
|
+
schemaLines.push('// Forward declarations for circular references');
|
|
611
620
|
for (const sName of forwardRefs) {
|
|
612
621
|
if (!inlineSchemas.has(sName))
|
|
613
622
|
continue;
|
|
@@ -615,8 +624,8 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
615
624
|
const typeN = sanitizeTypeName(sName);
|
|
616
625
|
emittedVarNames.add(varN);
|
|
617
626
|
emittedTypeNames.add(typeN);
|
|
618
|
-
|
|
619
|
-
|
|
627
|
+
schemaLines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`);
|
|
628
|
+
schemaLines.push('');
|
|
620
629
|
}
|
|
621
630
|
}
|
|
622
631
|
for (const sName of inlineSorted) {
|
|
@@ -624,24 +633,24 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
624
633
|
emittedVarNames.add(varN);
|
|
625
634
|
const zodCode = generatedCode.get(sName);
|
|
626
635
|
if (forwardRefs.has(sName)) {
|
|
627
|
-
|
|
636
|
+
schemaLines.push(`const _${varN} = ${zodCode}`);
|
|
628
637
|
}
|
|
629
638
|
else {
|
|
630
|
-
|
|
639
|
+
schemaLines.push(`const ${varN} = ${zodCode}`);
|
|
631
640
|
}
|
|
632
|
-
|
|
641
|
+
schemaLines.push('');
|
|
633
642
|
}
|
|
634
643
|
}
|
|
635
644
|
}
|
|
636
645
|
// Build Input schema (exported for pikku schema discovery)
|
|
637
646
|
if (hasInput && inputCode) {
|
|
638
|
-
|
|
639
|
-
|
|
647
|
+
schemaLines.push(`export const ${inputName} = ${inputCode}`);
|
|
648
|
+
schemaLines.push('');
|
|
640
649
|
}
|
|
641
650
|
// Build Output schema (exported for pikku schema discovery)
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
651
|
+
if (outputCode) {
|
|
652
|
+
schemaLines.push(`export const ${outputName} = ${outputCode}`);
|
|
653
|
+
schemaLines.push('');
|
|
645
654
|
}
|
|
646
655
|
const description = humanDescription(parsed);
|
|
647
656
|
const method = parsed.method.toUpperCase();
|
|
@@ -651,9 +660,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
651
660
|
}
|
|
652
661
|
if (hasInput)
|
|
653
662
|
funcConfig.push(` input: ${inputName},`);
|
|
654
|
-
|
|
655
|
-
funcConfig.push(` output: ${outputName},`);
|
|
656
|
-
}
|
|
663
|
+
funcConfig.push(` output: ${outputName},`);
|
|
657
664
|
if (errorClasses.length > 0) {
|
|
658
665
|
funcConfig.push(` errors: [${errorClasses.join(', ')}],`);
|
|
659
666
|
}
|
|
@@ -665,13 +672,16 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
665
672
|
const funcParams = hasInput
|
|
666
673
|
? `{ ${camelName} }, ${inputParamName}`
|
|
667
674
|
: `{ ${camelName} }`;
|
|
668
|
-
const returnCast =
|
|
675
|
+
const returnCast = ' as any';
|
|
669
676
|
funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`, ' },');
|
|
670
677
|
lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`);
|
|
671
678
|
lines.push(funcConfig.join('\n'));
|
|
672
679
|
lines.push('})');
|
|
673
680
|
lines.push('');
|
|
674
|
-
return
|
|
681
|
+
return {
|
|
682
|
+
functionFile: lines.join('\n'),
|
|
683
|
+
schemasFile: schemaExports.length > 0 ? schemaLines.join('\n') : undefined,
|
|
684
|
+
};
|
|
675
685
|
}
|
|
676
686
|
function buildInputSchema(parsed, ctx) {
|
|
677
687
|
const props = [];
|
|
@@ -746,6 +756,28 @@ function formatParamProp(param, ctx, camelCase = false) {
|
|
|
746
756
|
const outputKey = camelCase ? snakeToCamel(param.name) : param.name;
|
|
747
757
|
return ` ${safeKey(outputKey)}: ${desc},`;
|
|
748
758
|
}
|
|
759
|
+
const isBareString = (schema) => schema?.type === 'string' &&
|
|
760
|
+
!schema.format &&
|
|
761
|
+
!schema.enum &&
|
|
762
|
+
!schema.pattern &&
|
|
763
|
+
schema.const === undefined;
|
|
764
|
+
/**
|
|
765
|
+
* A response the spec leaves untyped, or types as a bare string (or a list of
|
|
766
|
+
* them) while serving JSON, carries no shape a caller can rely on: spec
|
|
767
|
+
* generators such as Restler emit exactly that as a placeholder for "some
|
|
768
|
+
* object". `z.unknown()` says so honestly instead of promising a string the
|
|
769
|
+
* API never returns. Text responses keep their string type.
|
|
770
|
+
*/
|
|
771
|
+
function isVagueResponse(parsed) {
|
|
772
|
+
const schema = parsed.responseSchema;
|
|
773
|
+
if (!schema)
|
|
774
|
+
return true;
|
|
775
|
+
if (parsed.responseMediaType && !/json/i.test(parsed.responseMediaType)) {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
return (isBareString(schema) ||
|
|
779
|
+
(schema.type === 'array' && isBareString(schema.items)));
|
|
780
|
+
}
|
|
749
781
|
function buildOutputSchema(schema, ctx) {
|
|
750
782
|
// For output schemas, filter out writeOnly properties.
|
|
751
783
|
// Only create a filtered copy if there are actually writeOnly properties,
|
|
@@ -779,398 +811,416 @@ function generateIndexFile(functionExports, extras) {
|
|
|
779
811
|
/**
|
|
780
812
|
* Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
|
|
781
813
|
* delegated login. It performs the upstream login call described by the auth
|
|
782
|
-
* config, extracts the token,
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
* `@pikku/better-auth`'s
|
|
814
|
+
* config, extracts the token, optionally looks the user up with it, reads
|
|
815
|
+
* identity claims from the decoded JWT payload (base64url, NOT
|
|
816
|
+
* signature-verified — the token was just received over TLS from the login
|
|
817
|
+
* we ourselves performed), the login response or the lookup, and returns an
|
|
818
|
+
* identity object structurally compatible with `@pikku/better-auth`'s
|
|
819
|
+
* `UpstreamIdentity` — without depending on it.
|
|
787
820
|
*/
|
|
788
821
|
function generateUpstreamAuthFile(vars, authConfig) {
|
|
789
822
|
const { pascalName } = vars;
|
|
790
|
-
const delegated = authConfig.delegated;
|
|
823
|
+
const delegated = DelegatedLoginSchema.parse(authConfig.delegated);
|
|
791
824
|
const claims = delegated.claims;
|
|
825
|
+
const source = claims.source ?? (delegated.identity ? 'identity' : 'response');
|
|
792
826
|
const namePaths = claims.name
|
|
793
827
|
? Array.isArray(claims.name)
|
|
794
828
|
? claims.name
|
|
795
829
|
: [claims.name]
|
|
796
830
|
: [];
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
lines.push(`export interface ${pascalName}UpstreamCredentials {`);
|
|
808
|
-
lines.push(' email?: string');
|
|
809
|
-
lines.push(' password?: string');
|
|
810
|
-
lines.push(' apiKey?: string');
|
|
811
|
-
lines.push('}');
|
|
812
|
-
lines.push('');
|
|
813
|
-
lines.push('const pick = (obj: unknown, path: string): unknown =>');
|
|
814
|
-
lines.push(' path');
|
|
815
|
-
lines.push(" .split('.')");
|
|
816
|
-
lines.push(' .reduce<any>(');
|
|
817
|
-
lines.push(" (o, key) => (o && typeof o === 'object' ? o[key] : undefined),");
|
|
818
|
-
lines.push(' obj');
|
|
819
|
-
lines.push(' )');
|
|
820
|
-
lines.push('');
|
|
821
|
-
lines.push('const str = (value: unknown): string | undefined =>');
|
|
822
|
-
lines.push(" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined");
|
|
823
|
-
lines.push('');
|
|
824
|
-
lines.push('/** Decode a JWT payload without verifying — see file docblock. */');
|
|
825
|
-
lines.push('const decodeJwtPayload = (token: string): unknown => {');
|
|
826
|
-
lines.push(" const part = token.split('.')[1]");
|
|
827
|
-
lines.push(' if (!part) return undefined');
|
|
828
|
-
lines.push(' try {');
|
|
829
|
-
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)");
|
|
830
|
-
lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))");
|
|
831
|
-
lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))');
|
|
832
|
-
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))');
|
|
833
|
-
lines.push(' } catch {');
|
|
834
|
-
lines.push(' // Not a decodable JWT — caller treats it as missing claims.');
|
|
835
|
-
lines.push(' return undefined');
|
|
836
|
-
lines.push(' }');
|
|
837
|
-
lines.push('}');
|
|
838
|
-
lines.push('');
|
|
839
|
-
lines.push('/**');
|
|
840
|
-
lines.push(` * Verify credentials against the upstream ${vars.displayName} login and map`);
|
|
841
|
-
lines.push(' * its response onto an upstream identity. Returns null when the upstream');
|
|
842
|
-
lines.push(' * rejects the credentials; network errors propagate to the caller.');
|
|
843
|
-
lines.push(' */');
|
|
844
|
-
lines.push(`export const authenticate${pascalName}Upstream = async (`);
|
|
845
|
-
lines.push(` credentials: ${pascalName}UpstreamCredentials,`);
|
|
846
|
-
lines.push(' baseUrl: string');
|
|
847
|
-
lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`);
|
|
848
|
-
lines.push(' const headers: Record<string, string> = {');
|
|
849
|
-
lines.push(" 'Content-Type': 'application/json',");
|
|
850
|
-
for (const [header, value] of Object.entries(authConfig.extraHeaders ?? {})) {
|
|
851
|
-
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`);
|
|
852
|
-
}
|
|
853
|
-
lines.push(' }');
|
|
854
|
-
lines.push(' const body: Record<string, string> = {}');
|
|
831
|
+
const q = (value) => JSON.stringify(value);
|
|
832
|
+
const fieldName = (credential) => q(delegated.fields?.[credential] ?? credential);
|
|
833
|
+
const extraHeaders = Object.entries(authConfig.extraHeaders ?? {})
|
|
834
|
+
.map(([header, value]) => `\n ${q(header)}: ${q(value)},`)
|
|
835
|
+
.join('');
|
|
836
|
+
const tokenHeader = authHeaderValue(authConfig, 'token');
|
|
837
|
+
const fieldLines = [];
|
|
838
|
+
if (delegated.credentials.includes('login')) {
|
|
839
|
+
fieldLines.push(` if (login) fields[${fieldName('login')}] = login`);
|
|
840
|
+
}
|
|
855
841
|
if (delegated.credentials.includes('email')) {
|
|
856
|
-
|
|
842
|
+
fieldLines.push(` if (credentials.email ?? login) fields[${fieldName('email')}] = (credentials.email ?? login)!`);
|
|
857
843
|
}
|
|
858
844
|
if (delegated.credentials.includes('password')) {
|
|
859
|
-
|
|
845
|
+
fieldLines.push(` if (credentials.password) fields[${fieldName('password')}] = credentials.password`);
|
|
860
846
|
}
|
|
861
847
|
if (delegated.credentials.includes('apiKey')) {
|
|
862
|
-
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
848
|
+
fieldLines.push(` if (credentials.apiKey) headers[${q(delegated.apiKeyHeader)}] = credentials.apiKey`);
|
|
849
|
+
}
|
|
850
|
+
const sendFields = {
|
|
851
|
+
json: ` headers['Content-Type'] = 'application/json'
|
|
852
|
+
const body = Object.keys(fields).length > 0 ? JSON.stringify(fields) : undefined`,
|
|
853
|
+
form: ` headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
854
|
+
const body = new URLSearchParams(fields).toString()`,
|
|
855
|
+
query: ` for (const [key, value] of Object.entries(fields)) {
|
|
856
|
+
loginUrl.searchParams.set(key, value)
|
|
857
|
+
}
|
|
858
|
+
const body = undefined`,
|
|
859
|
+
}[delegated.encoding];
|
|
860
|
+
const identityLookup = delegated.identity
|
|
861
|
+
? `
|
|
862
|
+
const identityResponse = await fetch(\`\${root}${delegated.identity.path}\`, {
|
|
863
|
+
method: ${q(delegated.identity.method.toUpperCase())},
|
|
864
|
+
headers: { ...EXTRA_HEADERS, ${q(tokenHeader.header)}: ${tokenHeader.value} },
|
|
865
|
+
})
|
|
866
|
+
if (!identityResponse.ok) return null
|
|
867
|
+
const identity: unknown = await identityResponse.json()
|
|
868
|
+
`
|
|
869
|
+
: '';
|
|
870
|
+
const claimsExpr = {
|
|
871
|
+
jwt: 'decodeJwtPayload(token)',
|
|
872
|
+
response: 'data',
|
|
873
|
+
identity: delegated.identity ? 'identity' : 'data',
|
|
874
|
+
}[source];
|
|
875
|
+
const nameExpr = namePaths.length > 0
|
|
876
|
+
? `[${namePaths.map((p) => `str(pick(claims, ${q(p)}))`).join(', ')}]
|
|
877
|
+
.filter((part): part is string => Boolean(part))
|
|
878
|
+
.join(' ') || login`
|
|
879
|
+
: 'login';
|
|
880
|
+
const roleExpr = !claims.role
|
|
881
|
+
? 'undefined'
|
|
882
|
+
: delegated.roles
|
|
883
|
+
? `mapRole(str(pick(claims, ${q(claims.role)})))`
|
|
884
|
+
: `str(pick(claims, ${q(claims.role)}))`;
|
|
885
|
+
const expiresAtExpr = delegated.expiresAtPath
|
|
886
|
+
? `pick(data, ${q(delegated.expiresAtPath)})`
|
|
887
|
+
: source === 'jwt'
|
|
888
|
+
? `pick(claims, 'exp')`
|
|
889
|
+
: 'undefined';
|
|
890
|
+
return `export interface ${pascalName}UpstreamIdentity {
|
|
891
|
+
externalId: string
|
|
892
|
+
email: string
|
|
893
|
+
syntheticEmail?: boolean
|
|
894
|
+
name?: string
|
|
895
|
+
role?: string
|
|
896
|
+
tenantId?: string
|
|
897
|
+
credential: { token: string; expiresAt?: number; tenantId?: string }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export interface ${pascalName}UpstreamCredentials {
|
|
901
|
+
login?: string
|
|
902
|
+
email?: string
|
|
903
|
+
password?: string
|
|
904
|
+
apiKey?: string
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const EXTRA_HEADERS: Record<string, string> = {${extraHeaders}${extraHeaders ? '\n' : ''}}
|
|
908
|
+
${delegated.roles ? `\nconst ROLES: Record<string, string> = ${q(delegated.roles)}\n\nconst mapRole = (value: string | undefined) =>\n value === undefined ? undefined : ROLES[value]\n` : ''}
|
|
909
|
+
const pick = (obj: unknown, path: string): unknown =>
|
|
910
|
+
path
|
|
911
|
+
.split('.')
|
|
912
|
+
.reduce<any>(
|
|
913
|
+
(o, key) => (o && typeof o === 'object' ? o[key] : undefined),
|
|
914
|
+
obj
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
const str = (value: unknown): string | undefined =>
|
|
918
|
+
typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined
|
|
919
|
+
|
|
920
|
+
/** Decode a JWT payload without verifying — see file docblock. */
|
|
921
|
+
const decodeJwtPayload = (token: string): unknown => {
|
|
922
|
+
const part = token.split('.')[1]
|
|
923
|
+
if (!part) return undefined
|
|
924
|
+
try {
|
|
925
|
+
const pad = part + '==='.slice((part.length + 3) % 4)
|
|
926
|
+
const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))
|
|
927
|
+
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))
|
|
928
|
+
return JSON.parse(new TextDecoder().decode(bytes))
|
|
929
|
+
} catch {
|
|
930
|
+
// Not a decodable JWT — caller treats it as missing claims.
|
|
931
|
+
return undefined
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const syntheticEmail = (values: Record<string, string>) =>
|
|
936
|
+
${q(delegated.emailTemplate)}
|
|
937
|
+
.replace(/\\{(\\w+)\\}/g, (_, key: string) =>
|
|
938
|
+
(values[key] ?? '').replace(/[^A-Za-z0-9._+-]/g, '-')
|
|
939
|
+
)
|
|
940
|
+
.toLowerCase()
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Verify credentials against the upstream ${vars.displayName} login and map
|
|
944
|
+
* its response onto an upstream identity. Returns null when the upstream
|
|
945
|
+
* rejects the credentials; network errors propagate to the caller.
|
|
946
|
+
*/
|
|
947
|
+
export const authenticate${pascalName}Upstream = async (
|
|
948
|
+
credentials: ${pascalName}UpstreamCredentials,
|
|
949
|
+
baseUrl: string
|
|
950
|
+
): Promise<${pascalName}UpstreamIdentity | null> => {
|
|
951
|
+
const root = baseUrl.replace(/\\/+$/, '')
|
|
952
|
+
const login = credentials.login ?? credentials.email
|
|
953
|
+
const headers: Record<string, string> = { ...EXTRA_HEADERS }
|
|
954
|
+
const fields: Record<string, string> = {}
|
|
955
|
+
${fieldLines.join('\n')}
|
|
956
|
+
|
|
957
|
+
const loginUrl = new URL(\`\${root}${delegated.loginPath}\`)
|
|
958
|
+
${sendFields}
|
|
959
|
+
const response = await fetch(loginUrl, {
|
|
960
|
+
method: ${q(delegated.loginMethod.toUpperCase())},
|
|
961
|
+
headers,
|
|
962
|
+
body,
|
|
963
|
+
})
|
|
964
|
+
if (!response.ok) return null
|
|
965
|
+
|
|
966
|
+
const data: unknown = await response.json().catch(() => undefined)
|
|
967
|
+
const token = str(pick(data, ${q(delegated.tokenPath)}))
|
|
968
|
+
if (!token) return null
|
|
969
|
+
${identityLookup}
|
|
970
|
+
const claims = ${claimsExpr}
|
|
971
|
+
if (!claims) return null
|
|
972
|
+
|
|
973
|
+
const externalId = ${claims.externalId ? `str(pick(claims, ${q(claims.externalId)})) ?? login` : 'login'}
|
|
974
|
+
if (!externalId) return null
|
|
975
|
+
const who = login ?? externalId
|
|
976
|
+
const claimedEmail = ${claims.email ? `str(pick(claims, ${q(claims.email)}))` : 'undefined'}
|
|
977
|
+
const email =
|
|
978
|
+
claimedEmail ??
|
|
979
|
+
(who.includes('@')
|
|
980
|
+
? who.toLowerCase()
|
|
981
|
+
: syntheticEmail({ login: who, externalId, host: new URL(root).host }))
|
|
982
|
+
const name = ${nameExpr}
|
|
983
|
+
const role = ${roleExpr}
|
|
984
|
+
const tenantId = ${claims.tenantId ? `str(pick(claims, ${q(claims.tenantId)}))` : 'undefined'}
|
|
985
|
+
const expiresAtRaw = ${expiresAtExpr}
|
|
986
|
+
const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined
|
|
987
|
+
|
|
988
|
+
return {
|
|
989
|
+
externalId,
|
|
990
|
+
email,
|
|
991
|
+
syntheticEmail: claimedEmail === undefined,
|
|
992
|
+
name,
|
|
993
|
+
role,
|
|
994
|
+
tenantId,
|
|
995
|
+
credential: { token, expiresAt, tenantId },
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
`;
|
|
999
|
+
}
|
|
1000
|
+
const CREDENTIAL_SHAPES = {
|
|
1001
|
+
bearer: '{ token: string }',
|
|
1002
|
+
apikey: '{ apiKey: string }',
|
|
1003
|
+
basic: '{ username: string; password: string }',
|
|
1004
|
+
oauth2: '{ accessToken: string }',
|
|
1005
|
+
};
|
|
1006
|
+
/** The statement that puts the caller's credential on the request, if any. */
|
|
1007
|
+
function authHeaderLine(spec, flags, credential) {
|
|
1008
|
+
if (!credential)
|
|
1009
|
+
return undefined;
|
|
1010
|
+
const tokenExpr = {
|
|
1011
|
+
bearer: 'this.creds.token',
|
|
1012
|
+
apikey: 'this.creds.apiKey',
|
|
1013
|
+
secret: 'this.creds.apiKey',
|
|
1014
|
+
oauth2: 'this.creds.accessToken',
|
|
1015
|
+
basic: undefined,
|
|
1016
|
+
}[credential];
|
|
1017
|
+
if (!tokenExpr) {
|
|
1018
|
+
return 'headers.Authorization = `Basic ${btoa(String.fromCharCode(...new TextEncoder().encode(`${this.creds.username}:${this.creds.password}`)))}`';
|
|
1019
|
+
}
|
|
1020
|
+
if (flags.authConfig?.headerName) {
|
|
1021
|
+
const { header, value } = authHeaderValue(flags.authConfig, tokenExpr);
|
|
1022
|
+
return `headers[${JSON.stringify(header)}] = ${value}`;
|
|
1023
|
+
}
|
|
1024
|
+
if (credential === 'apikey' || credential === 'secret') {
|
|
1025
|
+
const scheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
1026
|
+
if (scheme?.name && scheme.in === 'header') {
|
|
1027
|
+
return `headers[${JSON.stringify(scheme.name)}] = ${tokenExpr}`;
|
|
1028
|
+
}
|
|
1029
|
+
if (scheme?.name && scheme.in === 'query') {
|
|
1030
|
+
return `url.searchParams.set(${JSON.stringify(scheme.name)}, ${tokenExpr})`;
|
|
1031
|
+
}
|
|
919
1032
|
}
|
|
920
|
-
|
|
921
|
-
lines.push('');
|
|
922
|
-
lines.push(' return {');
|
|
923
|
-
lines.push(' externalId,');
|
|
924
|
-
lines.push(' email,');
|
|
925
|
-
lines.push(' name,');
|
|
926
|
-
lines.push(' role,');
|
|
927
|
-
lines.push(' tenantId,');
|
|
928
|
-
lines.push(' credential: { token, expiresAt, tenantId },');
|
|
929
|
-
lines.push(' }');
|
|
930
|
-
lines.push('}');
|
|
931
|
-
lines.push('');
|
|
932
|
-
return lines.join('\n');
|
|
1033
|
+
return `headers.Authorization = \`Bearer \${${tokenExpr}}\``;
|
|
933
1034
|
}
|
|
934
1035
|
function generateServiceFile(spec, opPairs, vars, flags) {
|
|
935
|
-
const { name, pascalName, screamingName } = vars;
|
|
1036
|
+
const { name, camelName, pascalName, screamingName } = vars;
|
|
936
1037
|
const displayName = vars.displayName.replace(/'/g, '');
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
// Per-user credential: no special imports needed, creds passed via constructor
|
|
942
|
-
}
|
|
943
|
-
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
944
|
-
// OAuth2: the access token is resolved via the credential service and passed
|
|
945
|
-
// via the constructor — no special imports needed.
|
|
946
|
-
}
|
|
947
|
-
else if (flags.secret) {
|
|
948
|
-
lines.push(`import type { ${pascalName}Secrets } from './${name}.secret.js'`);
|
|
949
|
-
}
|
|
950
|
-
if (allErrorClasses.size > 0) {
|
|
951
|
-
lines.push(`import { ${[...allErrorClasses].sort().join(', ')} } from '@pikku/core/errors'`);
|
|
952
|
-
}
|
|
953
|
-
lines.push(`import type { TypedVariablesService } from '#pikku/variables/pikku-variables.gen.js'`);
|
|
954
|
-
lines.push('');
|
|
955
|
-
// Generate route map from parsed operations
|
|
1038
|
+
const credential = flags.credential ??
|
|
1039
|
+
(flags.oauth ? 'oauth2' : flags.secret ? 'secret' : undefined);
|
|
1040
|
+
const perUser = credential !== undefined && credential !== 'secret';
|
|
1041
|
+
const reauth = flags.authConfig?.delegated ? 'sign-in' : 'connect';
|
|
956
1042
|
const routes = {};
|
|
957
1043
|
for (const { parsed } of opPairs) {
|
|
958
|
-
const key = `${parsed.method.toUpperCase()} ${parsed.path}`;
|
|
959
1044
|
const route = {
|
|
960
1045
|
path: parsed.pathParams.map((p) => p.name),
|
|
961
1046
|
query: parsed.queryParams.map((p) => p.name),
|
|
962
1047
|
headers: parsed.headerParams.map((p) => p.name),
|
|
963
1048
|
};
|
|
1049
|
+
if (parsed.requestBodyMediaType?.includes('x-www-form-urlencoded')) {
|
|
1050
|
+
route.body = 'form';
|
|
1051
|
+
}
|
|
1052
|
+
else if (parsed.requestBodyMediaType?.includes('multipart/form-data')) {
|
|
1053
|
+
route.body = 'multipart';
|
|
1054
|
+
}
|
|
964
1055
|
if (parsed.errorResponses.length > 0) {
|
|
965
1056
|
route.errors = {};
|
|
966
1057
|
for (const err of parsed.errorResponses) {
|
|
967
1058
|
route.errors[err.statusCode] = err.description;
|
|
968
1059
|
}
|
|
969
1060
|
}
|
|
970
|
-
routes[
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
lines.push(' )');
|
|
985
|
-
lines.push('}');
|
|
986
|
-
lines.push('');
|
|
987
|
-
}
|
|
988
|
-
// Class declaration
|
|
989
|
-
lines.push(`export class ${pascalName}Service {`);
|
|
990
|
-
lines.push(' private baseUrl: string');
|
|
991
|
-
if (flags.credential && flags.credential !== 'oauth2') {
|
|
992
|
-
const credField = flags.credential === 'bearer' ? 'token' : 'apiKey';
|
|
993
|
-
lines.push('');
|
|
994
|
-
lines.push(` constructor(private creds: { ${credField}: string }, variables: TypedVariablesService) {`);
|
|
995
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
996
|
-
lines.push(' }');
|
|
997
|
-
}
|
|
998
|
-
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
999
|
-
lines.push('');
|
|
1000
|
-
lines.push(` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`);
|
|
1001
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1002
|
-
lines.push(' }');
|
|
1003
|
-
}
|
|
1004
|
-
else if (flags.secret) {
|
|
1005
|
-
lines.push('');
|
|
1006
|
-
lines.push(` constructor(private creds: ${pascalName}Secrets, variables: TypedVariablesService) {`);
|
|
1007
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1008
|
-
lines.push(' }');
|
|
1009
|
-
}
|
|
1010
|
-
else {
|
|
1011
|
-
lines.push('');
|
|
1012
|
-
lines.push(` constructor(variables: TypedVariablesService) {`);
|
|
1013
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1014
|
-
lines.push(' }');
|
|
1015
|
-
}
|
|
1016
|
-
lines.push('');
|
|
1017
|
-
// call() method — splits data into path/query/headers/body using route map
|
|
1018
|
-
lines.push(' async call<T>(');
|
|
1019
|
-
lines.push(" method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',");
|
|
1020
|
-
lines.push(' path: string,');
|
|
1021
|
-
lines.push(' data?: unknown');
|
|
1022
|
-
lines.push(' ): Promise<T> {');
|
|
1023
|
-
lines.push(' const input = data as Record<string, unknown> | undefined');
|
|
1024
|
-
if (flags.camelCase) {
|
|
1025
|
-
lines.push(' const rawData = input ? _toSnakeCase(input) : input');
|
|
1026
|
-
}
|
|
1027
|
-
lines.push(' const route = ROUTES[`${method} ${path}`]');
|
|
1028
|
-
lines.push(' let endpoint = path');
|
|
1029
|
-
lines.push(' let body: Record<string, unknown> | undefined');
|
|
1030
|
-
lines.push(' const query: Record<string, string> = {}');
|
|
1031
|
-
lines.push(' const headers: Record<string, string> = {');
|
|
1032
|
-
lines.push(" 'Content-Type': 'application/json',");
|
|
1033
|
-
for (const [header, value] of Object.entries(flags.authConfig?.extraHeaders ?? {})) {
|
|
1034
|
-
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`);
|
|
1035
|
-
}
|
|
1036
|
-
lines.push(' }');
|
|
1037
|
-
lines.push('');
|
|
1038
|
-
// When camelCase flag is on, use rawData (snake_case converted) for route matching
|
|
1061
|
+
routes[`${parsed.method.toUpperCase()} ${parsed.path}`] = route;
|
|
1062
|
+
}
|
|
1063
|
+
const errorClasses = [
|
|
1064
|
+
...new Set([
|
|
1065
|
+
...Object.values(STATUS_TO_ERROR),
|
|
1066
|
+
...(perUser ? ['CredentialRejectedError'] : []),
|
|
1067
|
+
]),
|
|
1068
|
+
].sort();
|
|
1069
|
+
const credsParam = credential === 'secret'
|
|
1070
|
+
? `private creds: ${pascalName}Secrets, `
|
|
1071
|
+
: credential
|
|
1072
|
+
? `private creds: ${CREDENTIAL_SHAPES[credential]}, `
|
|
1073
|
+
: '';
|
|
1074
|
+
const authLine = authHeaderLine(spec, flags, credential);
|
|
1039
1075
|
const dataVar = flags.camelCase ? 'rawData' : 'input';
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1076
|
+
const extraHeaders = Object.entries(flags.authConfig?.extraHeaders ?? {})
|
|
1077
|
+
.map(([h, v]) => `\n ${JSON.stringify(h)}: ${JSON.stringify(v)},`)
|
|
1078
|
+
.join('');
|
|
1079
|
+
const unauthorized = perUser
|
|
1080
|
+
? `throw new CredentialRejectedError(${JSON.stringify(camelName)}, ${JSON.stringify(reauth)})`
|
|
1081
|
+
: 'throw new UnauthorizedError(errorMessage)';
|
|
1082
|
+
const parseJson = flags.camelCase
|
|
1083
|
+
? `const result = JSON.parse(text)
|
|
1084
|
+
return (typeof result === 'object' && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T`
|
|
1085
|
+
: 'return JSON.parse(text) as T';
|
|
1086
|
+
return `${credential === 'secret' ? `import type { ${pascalName}Secrets } from './${name}.secret.js'\n` : ''}import { ${errorClasses.join(', ')} } from '@pikku/core/errors'
|
|
1087
|
+
import type { TypedVariablesService } from '#pikku/addon/variables/pikku-variables.gen.js'
|
|
1088
|
+
|
|
1089
|
+
const ROUTES: Record<string, { path: string[], query: string[], headers: string[], body?: 'form' | 'multipart', errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}
|
|
1090
|
+
${flags.camelCase
|
|
1091
|
+
? `
|
|
1092
|
+
function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {
|
|
1093
|
+
return Object.fromEntries(
|
|
1094
|
+
Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => \`_\${c.toLowerCase()}\`), v])
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {
|
|
1099
|
+
return Object.fromEntries(
|
|
1100
|
+
Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])
|
|
1101
|
+
)
|
|
1102
|
+
}
|
|
1103
|
+
`
|
|
1104
|
+
: ''}
|
|
1105
|
+
function _upstreamMessage(text: string): string | undefined {
|
|
1106
|
+
let parsed: unknown
|
|
1107
|
+
try {
|
|
1108
|
+
parsed = JSON.parse(text)
|
|
1109
|
+
} catch {
|
|
1110
|
+
const plain = text.trim()
|
|
1111
|
+
return plain && !plain.startsWith('<') ? plain.slice(0, 200) : undefined
|
|
1112
|
+
}
|
|
1113
|
+
const body = (parsed ?? {}) as Record<string, unknown>
|
|
1114
|
+
const nested = (body.error ?? {}) as Record<string, unknown>
|
|
1115
|
+
const found = [nested.message, body.message, body.detail, body.title, body.error_description, body.error].find(
|
|
1116
|
+
(value): value is string => typeof value === 'string' && value.trim() !== ''
|
|
1117
|
+
)
|
|
1118
|
+
return found?.trim().slice(0, 200)
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export class ${pascalName}Service {
|
|
1122
|
+
constructor(${credsParam}private variables: TypedVariablesService) {}
|
|
1123
|
+
|
|
1124
|
+
private async baseUrl(): Promise<string> {
|
|
1125
|
+
const baseUrl = await this.variables.get('${screamingName}_BASE_URL')
|
|
1126
|
+
if (!baseUrl) {
|
|
1127
|
+
throw new Error('${screamingName}_BASE_URL is not set — point it at the ${inTemplate(displayName)} API root')
|
|
1128
|
+
}
|
|
1129
|
+
return String(baseUrl).replace(/\\/+$/, '')
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async call<T>(
|
|
1133
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
|
1134
|
+
path: string,
|
|
1135
|
+
data?: unknown
|
|
1136
|
+
): Promise<T> {
|
|
1137
|
+
const input = data as Record<string, unknown> | undefined${flags.camelCase ? '\n const rawData = input ? _toSnakeCase(input) : input' : ''}
|
|
1138
|
+
const route = ROUTES[\`\${method} \${path}\`]
|
|
1139
|
+
let endpoint = path
|
|
1140
|
+
let body: Record<string, unknown> | undefined
|
|
1141
|
+
const query: Record<string, string> = {}
|
|
1142
|
+
const headers: Record<string, string> = {${extraHeaders}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (${dataVar} && route) {
|
|
1146
|
+
for (const param of route.path) {
|
|
1147
|
+
if (${dataVar}[param] !== undefined) {
|
|
1148
|
+
endpoint = endpoint.replace(\`{\${param}}\`, encodeURIComponent(String(${dataVar}[param])))
|
|
1099
1149
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
lines.push(' body: body ? JSON.stringify(body) : undefined,');
|
|
1105
|
-
lines.push(' })');
|
|
1106
|
-
}
|
|
1107
|
-
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1108
|
-
lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`');
|
|
1109
|
-
lines.push('');
|
|
1110
|
-
lines.push(' const response = await fetch(url.toString(), {');
|
|
1111
|
-
lines.push(' method,');
|
|
1112
|
-
lines.push(' headers,');
|
|
1113
|
-
lines.push(' body: body ? JSON.stringify(body) : undefined,');
|
|
1114
|
-
lines.push(' })');
|
|
1115
|
-
}
|
|
1116
|
-
else if (flags.secret) {
|
|
1117
|
-
// Auth-config override wins, else use apiKey details from spec if available
|
|
1118
|
-
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
1119
|
-
if (flags.authConfig?.headerName) {
|
|
1120
|
-
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
|
|
1121
|
-
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1150
|
+
}
|
|
1151
|
+
for (const param of route.query) {
|
|
1152
|
+
if (${dataVar}[param] !== undefined) {
|
|
1153
|
+
query[param] = String(${dataVar}[param])
|
|
1122
1154
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1155
|
+
}
|
|
1156
|
+
for (const param of route.headers) {
|
|
1157
|
+
if (${dataVar}[param] !== undefined) {
|
|
1158
|
+
headers[param] = String(${dataVar}[param])
|
|
1125
1159
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1160
|
+
}
|
|
1161
|
+
const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])
|
|
1162
|
+
const remaining = Object.fromEntries(
|
|
1163
|
+
Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))
|
|
1164
|
+
)
|
|
1165
|
+
if (Object.keys(remaining).length > 0) {
|
|
1166
|
+
body = remaining
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const url = new URL(\`\${await this.baseUrl()}\${endpoint}\`)
|
|
1171
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1172
|
+
url.searchParams.set(key, value)
|
|
1173
|
+
}
|
|
1174
|
+
${authLine ? ` ${authLine}\n` : ''}
|
|
1175
|
+
let payload: string | FormData | undefined
|
|
1176
|
+
if (body && route?.body === 'form') {
|
|
1177
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
1178
|
+
payload = new URLSearchParams(
|
|
1179
|
+
Object.entries(body).map(([k, v]) => [k, String(v)])
|
|
1180
|
+
).toString()
|
|
1181
|
+
} else if (body && route?.body === 'multipart') {
|
|
1182
|
+
const form = new FormData()
|
|
1183
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1184
|
+
form.append(k, v instanceof Blob ? v : String(v))
|
|
1185
|
+
}
|
|
1186
|
+
payload = form
|
|
1187
|
+
} else if (body) {
|
|
1188
|
+
headers['Content-Type'] = 'application/json'
|
|
1189
|
+
payload = JSON.stringify(body)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const response = await fetch(url.toString(), { method, headers, body: payload })
|
|
1193
|
+
|
|
1194
|
+
if (!response.ok) {
|
|
1195
|
+
const errorText = await response.text()
|
|
1196
|
+
const errorMessage = _upstreamMessage(errorText) ?? route?.errors?.[response.status] ?? \`HTTP \${response.status}\`
|
|
1197
|
+
switch (response.status) {
|
|
1198
|
+
case 400: throw new BadRequestError(errorMessage)
|
|
1199
|
+
case 401: ${unauthorized}
|
|
1200
|
+
case 403: throw new ForbiddenError(errorMessage)
|
|
1201
|
+
case 404: throw new NotFoundError(errorMessage)
|
|
1202
|
+
case 405: throw new MethodNotAllowedError(errorMessage)
|
|
1203
|
+
case 409: throw new ConflictError(errorMessage)
|
|
1204
|
+
case 422: throw new UnprocessableContentError(errorMessage)
|
|
1205
|
+
case 429: throw new TooManyRequestsError(errorMessage)
|
|
1206
|
+
case 500: throw new InternalServerError(errorMessage)
|
|
1207
|
+
default: throw new Error(\`${inTemplate(displayName)} API error (\${response.status}): \${errorMessage}\`)
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const text = await response.text()
|
|
1212
|
+
if (!text) return undefined as T
|
|
1213
|
+
if (/json/i.test(response.headers.get('content-type') ?? 'application/json')) {
|
|
1214
|
+
${parseJson}
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
return JSON.parse(text) as T
|
|
1218
|
+
} catch {
|
|
1219
|
+
return text as T
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
`;
|
|
1174
1224
|
}
|
|
1175
1225
|
function generateCredentialFile(spec, vars) {
|
|
1176
1226
|
const { camelName, screamingName } = vars;
|
|
@@ -1232,20 +1282,20 @@ function generateCredentialFile(spec, vars) {
|
|
|
1232
1282
|
function generateVariableFile(spec, vars) {
|
|
1233
1283
|
const { camelName, screamingName } = vars;
|
|
1234
1284
|
const displayName = vars.displayName.replace(/'/g, '');
|
|
1235
|
-
const serverUrls = spec.serverUrls
|
|
1236
|
-
const defaultUrl = serverUrls[0];
|
|
1285
|
+
const serverUrls = spec.serverUrls;
|
|
1237
1286
|
const lines = [];
|
|
1238
1287
|
lines.push("import { z } from 'zod'");
|
|
1239
1288
|
lines.push("import { defineVariable } from '@pikku/core/variable'");
|
|
1240
1289
|
lines.push('');
|
|
1241
1290
|
const schemaVarName = `${camelName}BaseUrlSchema`;
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1291
|
+
const absolute = serverUrls.filter((url) => /^https?:\/\//i.test(url));
|
|
1292
|
+
const describe = [
|
|
1293
|
+
`Base URL of the ${displayName} API`,
|
|
1294
|
+
...(absolute.length > 1 ? [`e.g. ${absolute.join(', ')}`] : []),
|
|
1295
|
+
].join(' — ');
|
|
1296
|
+
// Any instance of the API can be configured, so the spec's server list is
|
|
1297
|
+
// a default, never an allow-list.
|
|
1298
|
+
lines.push(`export const ${schemaVarName} = z.string().url()${absolute[0] ? `.default(${JSON.stringify(absolute[0])})` : ''}.describe(${JSON.stringify(describe)})`);
|
|
1249
1299
|
lines.push('');
|
|
1250
1300
|
lines.push(`defineVariable({`);
|
|
1251
1301
|
lines.push(` name: '${screamingName}_BASE_URL',`);
|