@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/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 funcCode = generateFunctionFile(named, parsed, vars, funcCtx, spec, flags, inlineSchemas, sharedWithDeps, schemaIdentityMap);
285
- files[`src/functions/${named.functionName}.function.ts`] = funcCode;
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
- if (parsed.responseSchema) {
493
- outputCode = buildOutputSchema(parsed.responseSchema, ctx);
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 needsZod = hasInput || !!parsed.responseSchema || inlineSchemas.size > 0;
520
- if (needsZod) {
521
- lines.push("import { z } from 'zod'");
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
- lines.push(`import { ${schemaImports.join(', ')} } from '../${name}.types.js'`);
555
+ schemaLines.push(`import { ${schemaImports.join(', ')} } from '../${name}.types.js'`);
547
556
  }
548
557
  }
549
- lines.push('');
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
- lines.push('// Forward declarations for circular references');
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
- lines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`);
619
- lines.push('');
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
- lines.push(`const _${varN} = ${zodCode}`);
636
+ schemaLines.push(`const _${varN} = ${zodCode}`);
628
637
  }
629
638
  else {
630
- lines.push(`const ${varN} = ${zodCode}`);
639
+ schemaLines.push(`const ${varN} = ${zodCode}`);
631
640
  }
632
- lines.push('');
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
- lines.push(`export const ${inputName} = ${inputCode}`);
639
- lines.push('');
647
+ schemaLines.push(`export const ${inputName} = ${inputCode}`);
648
+ schemaLines.push('');
640
649
  }
641
650
  // Build Output schema (exported for pikku schema discovery)
642
- if (parsed.responseSchema && outputCode) {
643
- lines.push(`export const ${outputName} = ${outputCode}`);
644
- lines.push('');
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
- if (parsed.responseSchema) {
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 = parsed.responseSchema ? ' as any' : '';
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 lines.join('\n');
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, reads identity claims from the decoded JWT
783
- * payload (base64url, NOT signature-verified — the token was just received
784
- * over TLS from the login we ourselves performed) or the response body, and
785
- * returns an identity object structurally compatible with
786
- * `@pikku/better-auth`'s `UpstreamIdentity` — without depending on it.
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 lines = [];
798
- lines.push(`export interface ${pascalName}UpstreamIdentity {`);
799
- lines.push(' externalId: string');
800
- lines.push(' email: string');
801
- lines.push(' name?: string');
802
- lines.push(' role?: string');
803
- lines.push(' tenantId?: string');
804
- lines.push(' credential: { token: string; expiresAt?: number; tenantId?: string }');
805
- lines.push('}');
806
- lines.push('');
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
- lines.push(' if (credentials.email) body.email = credentials.email');
842
+ fieldLines.push(` if (credentials.email ?? login) fields[${fieldName('email')}] = (credentials.email ?? login)!`);
857
843
  }
858
844
  if (delegated.credentials.includes('password')) {
859
- lines.push(' if (credentials.password) body.password = credentials.password');
845
+ fieldLines.push(` if (credentials.password) fields[${fieldName('password')}] = credentials.password`);
860
846
  }
861
847
  if (delegated.credentials.includes('apiKey')) {
862
- lines.push(` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`);
863
- }
864
- lines.push('');
865
- lines.push(` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`);
866
- lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`);
867
- lines.push(' headers,');
868
- lines.push(' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,');
869
- lines.push(' })');
870
- lines.push(' if (!response.ok) return null');
871
- lines.push('');
872
- lines.push(' const data: unknown = await response.json()');
873
- lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`);
874
- lines.push(' if (!token) return null');
875
- lines.push('');
876
- if (claims.source === 'jwt') {
877
- lines.push(' const claims = decodeJwtPayload(token)');
878
- }
879
- else {
880
- lines.push(' const claims = data');
881
- }
882
- lines.push(' if (!claims) return null');
883
- lines.push('');
884
- lines.push(` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`);
885
- lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`);
886
- lines.push(' if (!externalId || !email) return null');
887
- lines.push('');
888
- if (namePaths.length > 0) {
889
- const parts = namePaths
890
- .map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
891
- .join(', ');
892
- lines.push(` const name = [${parts}]`);
893
- lines.push(' .filter((part): part is string => Boolean(part))');
894
- lines.push(" .join(' ') || undefined");
895
- }
896
- else {
897
- lines.push(' const name = undefined');
898
- }
899
- if (claims.role) {
900
- lines.push(` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`);
901
- }
902
- else {
903
- lines.push(' const role = undefined');
904
- }
905
- if (claims.tenantId) {
906
- lines.push(` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`);
907
- }
908
- else {
909
- lines.push(' const tenantId = undefined');
910
- }
911
- if (delegated.expiresAtPath) {
912
- lines.push(` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`);
913
- }
914
- else if (claims.source === 'jwt') {
915
- lines.push(" const expiresAtRaw = pick(claims, 'exp')");
916
- }
917
- else {
918
- lines.push(' const expiresAtRaw = undefined');
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
- lines.push(" const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined");
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 lines = [];
938
- // Always import all error classes used in the switch statement
939
- const allErrorClasses = new Set(Object.values(STATUS_TO_ERROR));
940
- if (flags.credential && flags.credential !== 'oauth2') {
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[key] = route;
971
- }
972
- lines.push(`const ROUTES: Record<string, { path: string[], query: string[], headers: string[], errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}`);
973
- lines.push('');
974
- if (flags.camelCase) {
975
- lines.push('function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {');
976
- lines.push(' return Object.fromEntries(');
977
- lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])');
978
- lines.push(' )');
979
- lines.push('}');
980
- lines.push('');
981
- lines.push('function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {');
982
- lines.push(' return Object.fromEntries(');
983
- lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])');
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
- lines.push(` if (${dataVar} && route) {`);
1041
- lines.push(' // Interpolate path params');
1042
- lines.push(' for (const param of route.path) {');
1043
- lines.push(` if (${dataVar}[param] !== undefined) {`);
1044
- lines.push(` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`);
1045
- lines.push(' }');
1046
- lines.push(' }');
1047
- lines.push(' // Extract query params');
1048
- lines.push(' for (const param of route.query) {');
1049
- lines.push(` if (${dataVar}[param] !== undefined) {`);
1050
- lines.push(` query[param] = String(${dataVar}[param])`);
1051
- lines.push(' }');
1052
- lines.push(' }');
1053
- lines.push(' // Extract header params');
1054
- lines.push(' for (const param of route.headers) {');
1055
- lines.push(` if (${dataVar}[param] !== undefined) {`);
1056
- lines.push(` headers[param] = String(${dataVar}[param])`);
1057
- lines.push(' }');
1058
- lines.push(' }');
1059
- lines.push(' // Everything else goes into body');
1060
- lines.push(' const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])');
1061
- lines.push(' const remaining = Object.fromEntries(');
1062
- lines.push(` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`);
1063
- lines.push(' )');
1064
- lines.push(' if (Object.keys(remaining).length > 0) {');
1065
- lines.push(' body = remaining');
1066
- lines.push(' }');
1067
- lines.push(' }');
1068
- lines.push('');
1069
- lines.push(' const url = new URL(`${this.baseUrl}${endpoint}`)');
1070
- lines.push(' for (const [key, value] of Object.entries(query)) {');
1071
- lines.push(' url.searchParams.set(key, value)');
1072
- lines.push(' }');
1073
- lines.push('');
1074
- if (flags.credential && flags.credential !== 'oauth2') {
1075
- // Per-user credential: use creds from wire.getCredentials()
1076
- if (flags.credential === 'bearer') {
1077
- if (flags.authConfig?.headerName) {
1078
- // Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
1079
- const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.token');
1080
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
1081
- }
1082
- else {
1083
- lines.push(' headers.Authorization = `Bearer ${this.creds.token}`');
1084
- }
1085
- }
1086
- else {
1087
- // apikey: auth-config override wins, else check spec for custom header name
1088
- const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
1089
- if (flags.authConfig?.headerName) {
1090
- const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
1091
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
1092
- }
1093
- else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
1094
- lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
1095
- }
1096
- else {
1097
- lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`');
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
- lines.push('');
1101
- lines.push(' const response = await fetch(url.toString(), {');
1102
- lines.push(' method,');
1103
- lines.push(' headers,');
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
- else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
1124
- lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
1155
+ }
1156
+ for (const param of route.headers) {
1157
+ if (${dataVar}[param] !== undefined) {
1158
+ headers[param] = String(${dataVar}[param])
1125
1159
  }
1126
- else {
1127
- lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`');
1128
- }
1129
- lines.push('');
1130
- lines.push(' const response = await fetch(url.toString(), {');
1131
- lines.push(' method,');
1132
- lines.push(' headers,');
1133
- lines.push(' body: body ? JSON.stringify(body) : undefined,');
1134
- lines.push(' })');
1135
- }
1136
- else {
1137
- lines.push(' const response = await fetch(url.toString(), {');
1138
- lines.push(' method,');
1139
- lines.push(' headers,');
1140
- lines.push(' body: body ? JSON.stringify(body) : undefined,');
1141
- lines.push(' })');
1142
- }
1143
- lines.push('');
1144
- lines.push(' if (!response.ok) {');
1145
- lines.push(' const errorText = await response.text()');
1146
- lines.push(' const errorMessage = route?.errors?.[response.status] ?? errorText');
1147
- lines.push(' switch (response.status) {');
1148
- lines.push(' case 400: throw new BadRequestError(errorMessage)');
1149
- lines.push(' case 401: throw new UnauthorizedError(errorMessage)');
1150
- lines.push(' case 403: throw new ForbiddenError(errorMessage)');
1151
- lines.push(' case 404: throw new NotFoundError(errorMessage)');
1152
- lines.push(' case 405: throw new MethodNotAllowedError(errorMessage)');
1153
- lines.push(' case 409: throw new ConflictError(errorMessage)');
1154
- lines.push(' case 422: throw new UnprocessableContentError(errorMessage)');
1155
- lines.push(' case 429: throw new TooManyRequestsError(errorMessage)');
1156
- lines.push(' case 500: throw new InternalServerError(errorMessage)');
1157
- lines.push(` default: throw new Error(\`${displayName} API error (\${response.status}): \${errorText}\`)`);
1158
- lines.push(' }');
1159
- lines.push(' }');
1160
- lines.push('');
1161
- lines.push(' const text = await response.text()');
1162
- lines.push(' if (!text) return {} as T');
1163
- if (flags.camelCase) {
1164
- lines.push(' const result = JSON.parse(text)');
1165
- lines.push(' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T');
1166
- }
1167
- else {
1168
- lines.push(' return JSON.parse(text) as T');
1169
- }
1170
- lines.push(' }');
1171
- lines.push('}');
1172
- lines.push('');
1173
- return lines.join('\n');
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.length > 0 ? 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
- if (serverUrls.length > 0) {
1243
- const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ');
1244
- lines.push(`export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`);
1245
- }
1246
- else {
1247
- lines.push(`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`);
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',`);