@pikku/openapi-parser 0.12.22 → 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 +16 -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 +415 -376
- 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
|
}
|
|
@@ -492,9 +493,9 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
492
493
|
if (hasInput) {
|
|
493
494
|
inputCode = buildInputSchema(parsed, ctx);
|
|
494
495
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
496
|
+
outputCode = isVagueResponse(parsed)
|
|
497
|
+
? 'z.unknown()'
|
|
498
|
+
: buildOutputSchema(parsed.responseSchema, ctx);
|
|
498
499
|
// Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
|
|
499
500
|
// can't infer the type (TS7056). Replace with z.any() to stay compilable.
|
|
500
501
|
const LINE_LIMIT = 500;
|
|
@@ -521,7 +522,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
521
522
|
const errorClasses = getErrorClassesForResponses(parsed.errorResponses);
|
|
522
523
|
const schemaExports = [
|
|
523
524
|
...(hasInput && inputCode ? [inputName] : []),
|
|
524
|
-
...(
|
|
525
|
+
...(outputCode ? [outputName] : []),
|
|
525
526
|
];
|
|
526
527
|
lines.push("import { pikkuSessionlessFunc } from '#pikku/addon/function'");
|
|
527
528
|
if (errorClasses.length > 0) {
|
|
@@ -647,7 +648,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
647
648
|
schemaLines.push('');
|
|
648
649
|
}
|
|
649
650
|
// Build Output schema (exported for pikku schema discovery)
|
|
650
|
-
if (
|
|
651
|
+
if (outputCode) {
|
|
651
652
|
schemaLines.push(`export const ${outputName} = ${outputCode}`);
|
|
652
653
|
schemaLines.push('');
|
|
653
654
|
}
|
|
@@ -659,9 +660,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
659
660
|
}
|
|
660
661
|
if (hasInput)
|
|
661
662
|
funcConfig.push(` input: ${inputName},`);
|
|
662
|
-
|
|
663
|
-
funcConfig.push(` output: ${outputName},`);
|
|
664
|
-
}
|
|
663
|
+
funcConfig.push(` output: ${outputName},`);
|
|
665
664
|
if (errorClasses.length > 0) {
|
|
666
665
|
funcConfig.push(` errors: [${errorClasses.join(', ')}],`);
|
|
667
666
|
}
|
|
@@ -673,7 +672,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchem
|
|
|
673
672
|
const funcParams = hasInput
|
|
674
673
|
? `{ ${camelName} }, ${inputParamName}`
|
|
675
674
|
: `{ ${camelName} }`;
|
|
676
|
-
const returnCast =
|
|
675
|
+
const returnCast = ' as any';
|
|
677
676
|
funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`, ' },');
|
|
678
677
|
lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`);
|
|
679
678
|
lines.push(funcConfig.join('\n'));
|
|
@@ -757,6 +756,28 @@ function formatParamProp(param, ctx, camelCase = false) {
|
|
|
757
756
|
const outputKey = camelCase ? snakeToCamel(param.name) : param.name;
|
|
758
757
|
return ` ${safeKey(outputKey)}: ${desc},`;
|
|
759
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
|
+
}
|
|
760
781
|
function buildOutputSchema(schema, ctx) {
|
|
761
782
|
// For output schemas, filter out writeOnly properties.
|
|
762
783
|
// Only create a filtered copy if there are actually writeOnly properties,
|
|
@@ -790,398 +811,416 @@ function generateIndexFile(functionExports, extras) {
|
|
|
790
811
|
/**
|
|
791
812
|
* Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
|
|
792
813
|
* delegated login. It performs the upstream login call described by the auth
|
|
793
|
-
* config, extracts the token,
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* `@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.
|
|
798
820
|
*/
|
|
799
821
|
function generateUpstreamAuthFile(vars, authConfig) {
|
|
800
822
|
const { pascalName } = vars;
|
|
801
|
-
const delegated = authConfig.delegated;
|
|
823
|
+
const delegated = DelegatedLoginSchema.parse(authConfig.delegated);
|
|
802
824
|
const claims = delegated.claims;
|
|
825
|
+
const source = claims.source ?? (delegated.identity ? 'identity' : 'response');
|
|
803
826
|
const namePaths = claims.name
|
|
804
827
|
? Array.isArray(claims.name)
|
|
805
828
|
? claims.name
|
|
806
829
|
: [claims.name]
|
|
807
830
|
: [];
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
lines.push(`export interface ${pascalName}UpstreamCredentials {`);
|
|
819
|
-
lines.push(' email?: string');
|
|
820
|
-
lines.push(' password?: string');
|
|
821
|
-
lines.push(' apiKey?: string');
|
|
822
|
-
lines.push('}');
|
|
823
|
-
lines.push('');
|
|
824
|
-
lines.push('const pick = (obj: unknown, path: string): unknown =>');
|
|
825
|
-
lines.push(' path');
|
|
826
|
-
lines.push(" .split('.')");
|
|
827
|
-
lines.push(' .reduce<any>(');
|
|
828
|
-
lines.push(" (o, key) => (o && typeof o === 'object' ? o[key] : undefined),");
|
|
829
|
-
lines.push(' obj');
|
|
830
|
-
lines.push(' )');
|
|
831
|
-
lines.push('');
|
|
832
|
-
lines.push('const str = (value: unknown): string | undefined =>');
|
|
833
|
-
lines.push(" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined");
|
|
834
|
-
lines.push('');
|
|
835
|
-
lines.push('/** Decode a JWT payload without verifying — see file docblock. */');
|
|
836
|
-
lines.push('const decodeJwtPayload = (token: string): unknown => {');
|
|
837
|
-
lines.push(" const part = token.split('.')[1]");
|
|
838
|
-
lines.push(' if (!part) return undefined');
|
|
839
|
-
lines.push(' try {');
|
|
840
|
-
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)");
|
|
841
|
-
lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))");
|
|
842
|
-
lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))');
|
|
843
|
-
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))');
|
|
844
|
-
lines.push(' } catch {');
|
|
845
|
-
lines.push(' // Not a decodable JWT — caller treats it as missing claims.');
|
|
846
|
-
lines.push(' return undefined');
|
|
847
|
-
lines.push(' }');
|
|
848
|
-
lines.push('}');
|
|
849
|
-
lines.push('');
|
|
850
|
-
lines.push('/**');
|
|
851
|
-
lines.push(` * Verify credentials against the upstream ${vars.displayName} login and map`);
|
|
852
|
-
lines.push(' * its response onto an upstream identity. Returns null when the upstream');
|
|
853
|
-
lines.push(' * rejects the credentials; network errors propagate to the caller.');
|
|
854
|
-
lines.push(' */');
|
|
855
|
-
lines.push(`export const authenticate${pascalName}Upstream = async (`);
|
|
856
|
-
lines.push(` credentials: ${pascalName}UpstreamCredentials,`);
|
|
857
|
-
lines.push(' baseUrl: string');
|
|
858
|
-
lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`);
|
|
859
|
-
lines.push(' const headers: Record<string, string> = {');
|
|
860
|
-
lines.push(" 'Content-Type': 'application/json',");
|
|
861
|
-
for (const [header, value] of Object.entries(authConfig.extraHeaders ?? {})) {
|
|
862
|
-
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`);
|
|
863
|
-
}
|
|
864
|
-
lines.push(' }');
|
|
865
|
-
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
|
+
}
|
|
866
841
|
if (delegated.credentials.includes('email')) {
|
|
867
|
-
|
|
842
|
+
fieldLines.push(` if (credentials.email ?? login) fields[${fieldName('email')}] = (credentials.email ?? login)!`);
|
|
868
843
|
}
|
|
869
844
|
if (delegated.credentials.includes('password')) {
|
|
870
|
-
|
|
845
|
+
fieldLines.push(` if (credentials.password) fields[${fieldName('password')}] = credentials.password`);
|
|
871
846
|
}
|
|
872
847
|
if (delegated.credentials.includes('apiKey')) {
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
+
}
|
|
930
1032
|
}
|
|
931
|
-
|
|
932
|
-
lines.push('');
|
|
933
|
-
lines.push(' return {');
|
|
934
|
-
lines.push(' externalId,');
|
|
935
|
-
lines.push(' email,');
|
|
936
|
-
lines.push(' name,');
|
|
937
|
-
lines.push(' role,');
|
|
938
|
-
lines.push(' tenantId,');
|
|
939
|
-
lines.push(' credential: { token, expiresAt, tenantId },');
|
|
940
|
-
lines.push(' }');
|
|
941
|
-
lines.push('}');
|
|
942
|
-
lines.push('');
|
|
943
|
-
return lines.join('\n');
|
|
1033
|
+
return `headers.Authorization = \`Bearer \${${tokenExpr}}\``;
|
|
944
1034
|
}
|
|
945
1035
|
function generateServiceFile(spec, opPairs, vars, flags) {
|
|
946
|
-
const { name, pascalName, screamingName } = vars;
|
|
1036
|
+
const { name, camelName, pascalName, screamingName } = vars;
|
|
947
1037
|
const displayName = vars.displayName.replace(/'/g, '');
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
// Per-user credential: no special imports needed, creds passed via constructor
|
|
953
|
-
}
|
|
954
|
-
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
955
|
-
// OAuth2: the access token is resolved via the credential service and passed
|
|
956
|
-
// via the constructor — no special imports needed.
|
|
957
|
-
}
|
|
958
|
-
else if (flags.secret) {
|
|
959
|
-
lines.push(`import type { ${pascalName}Secrets } from './${name}.secret.js'`);
|
|
960
|
-
}
|
|
961
|
-
if (allErrorClasses.size > 0) {
|
|
962
|
-
lines.push(`import { ${[...allErrorClasses].sort().join(', ')} } from '@pikku/core/errors'`);
|
|
963
|
-
}
|
|
964
|
-
lines.push(`import type { TypedVariablesService } from '#pikku/addon/variables/pikku-variables.gen.js'`);
|
|
965
|
-
lines.push('');
|
|
966
|
-
// 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';
|
|
967
1042
|
const routes = {};
|
|
968
1043
|
for (const { parsed } of opPairs) {
|
|
969
|
-
const key = `${parsed.method.toUpperCase()} ${parsed.path}`;
|
|
970
1044
|
const route = {
|
|
971
1045
|
path: parsed.pathParams.map((p) => p.name),
|
|
972
1046
|
query: parsed.queryParams.map((p) => p.name),
|
|
973
1047
|
headers: parsed.headerParams.map((p) => p.name),
|
|
974
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
|
+
}
|
|
975
1055
|
if (parsed.errorResponses.length > 0) {
|
|
976
1056
|
route.errors = {};
|
|
977
1057
|
for (const err of parsed.errorResponses) {
|
|
978
1058
|
route.errors[err.statusCode] = err.description;
|
|
979
1059
|
}
|
|
980
1060
|
}
|
|
981
|
-
routes[
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
lines.push(' )');
|
|
996
|
-
lines.push('}');
|
|
997
|
-
lines.push('');
|
|
998
|
-
}
|
|
999
|
-
// Class declaration
|
|
1000
|
-
lines.push(`export class ${pascalName}Service {`);
|
|
1001
|
-
lines.push(' private baseUrl: string');
|
|
1002
|
-
if (flags.credential && flags.credential !== 'oauth2') {
|
|
1003
|
-
const credField = flags.credential === 'bearer' ? 'token' : 'apiKey';
|
|
1004
|
-
lines.push('');
|
|
1005
|
-
lines.push(` constructor(private creds: { ${credField}: string }, variables: TypedVariablesService) {`);
|
|
1006
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1007
|
-
lines.push(' }');
|
|
1008
|
-
}
|
|
1009
|
-
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
1010
|
-
lines.push('');
|
|
1011
|
-
lines.push(` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`);
|
|
1012
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1013
|
-
lines.push(' }');
|
|
1014
|
-
}
|
|
1015
|
-
else if (flags.secret) {
|
|
1016
|
-
lines.push('');
|
|
1017
|
-
lines.push(` constructor(private creds: ${pascalName}Secrets, variables: TypedVariablesService) {`);
|
|
1018
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1019
|
-
lines.push(' }');
|
|
1020
|
-
}
|
|
1021
|
-
else {
|
|
1022
|
-
lines.push('');
|
|
1023
|
-
lines.push(` constructor(variables: TypedVariablesService) {`);
|
|
1024
|
-
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
1025
|
-
lines.push(' }');
|
|
1026
|
-
}
|
|
1027
|
-
lines.push('');
|
|
1028
|
-
// call() method — splits data into path/query/headers/body using route map
|
|
1029
|
-
lines.push(' async call<T>(');
|
|
1030
|
-
lines.push(" method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',");
|
|
1031
|
-
lines.push(' path: string,');
|
|
1032
|
-
lines.push(' data?: unknown');
|
|
1033
|
-
lines.push(' ): Promise<T> {');
|
|
1034
|
-
lines.push(' const input = data as Record<string, unknown> | undefined');
|
|
1035
|
-
if (flags.camelCase) {
|
|
1036
|
-
lines.push(' const rawData = input ? _toSnakeCase(input) : input');
|
|
1037
|
-
}
|
|
1038
|
-
lines.push(' const route = ROUTES[`${method} ${path}`]');
|
|
1039
|
-
lines.push(' let endpoint = path');
|
|
1040
|
-
lines.push(' let body: Record<string, unknown> | undefined');
|
|
1041
|
-
lines.push(' const query: Record<string, string> = {}');
|
|
1042
|
-
lines.push(' const headers: Record<string, string> = {');
|
|
1043
|
-
lines.push(" 'Content-Type': 'application/json',");
|
|
1044
|
-
for (const [header, value] of Object.entries(flags.authConfig?.extraHeaders ?? {})) {
|
|
1045
|
-
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`);
|
|
1046
|
-
}
|
|
1047
|
-
lines.push(' }');
|
|
1048
|
-
lines.push('');
|
|
1049
|
-
// 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);
|
|
1050
1075
|
const dataVar = flags.camelCase ? 'rawData' : 'input';
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
lines.push(' body: body ? JSON.stringify(body) : undefined,');
|
|
1125
|
-
lines.push(' })');
|
|
1126
|
-
}
|
|
1127
|
-
else if (flags.secret) {
|
|
1128
|
-
// Auth-config override wins, else use apiKey details from spec if available
|
|
1129
|
-
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
1130
|
-
if (flags.authConfig?.headerName) {
|
|
1131
|
-
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
|
|
1132
|
-
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
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])))
|
|
1133
1149
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1150
|
+
}
|
|
1151
|
+
for (const param of route.query) {
|
|
1152
|
+
if (${dataVar}[param] !== undefined) {
|
|
1153
|
+
query[param] = String(${dataVar}[param])
|
|
1136
1154
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1155
|
+
}
|
|
1156
|
+
for (const param of route.headers) {
|
|
1157
|
+
if (${dataVar}[param] !== undefined) {
|
|
1158
|
+
headers[param] = String(${dataVar}[param])
|
|
1139
1159
|
}
|
|
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
|
-
|
|
1174
|
-
if (
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
+
`;
|
|
1185
1224
|
}
|
|
1186
1225
|
function generateCredentialFile(spec, vars) {
|
|
1187
1226
|
const { camelName, screamingName } = vars;
|
|
@@ -1243,20 +1282,20 @@ function generateCredentialFile(spec, vars) {
|
|
|
1243
1282
|
function generateVariableFile(spec, vars) {
|
|
1244
1283
|
const { camelName, screamingName } = vars;
|
|
1245
1284
|
const displayName = vars.displayName.replace(/'/g, '');
|
|
1246
|
-
const serverUrls = spec.serverUrls
|
|
1247
|
-
const defaultUrl = serverUrls[0];
|
|
1285
|
+
const serverUrls = spec.serverUrls;
|
|
1248
1286
|
const lines = [];
|
|
1249
1287
|
lines.push("import { z } from 'zod'");
|
|
1250
1288
|
lines.push("import { defineVariable } from '@pikku/core/variable'");
|
|
1251
1289
|
lines.push('');
|
|
1252
1290
|
const schemaVarName = `${camelName}BaseUrlSchema`;
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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)})`);
|
|
1260
1299
|
lines.push('');
|
|
1261
1300
|
lines.push(`defineVariable({`);
|
|
1262
1301
|
lines.push(` name: '${screamingName}_BASE_URL',`);
|