@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/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
- if (parsed.responseSchema) {
496
- outputCode = buildOutputSchema(parsed.responseSchema, ctx);
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
- ...(parsed.responseSchema && outputCode ? [outputName] : []),
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 (parsed.responseSchema && outputCode) {
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
- if (parsed.responseSchema) {
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 = parsed.responseSchema ? ' as any' : '';
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, reads identity claims from the decoded JWT
794
- * payload (base64url, NOT signature-verified — the token was just received
795
- * over TLS from the login we ourselves performed) or the response body, and
796
- * returns an identity object structurally compatible with
797
- * `@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.
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 lines = [];
809
- lines.push(`export interface ${pascalName}UpstreamIdentity {`);
810
- lines.push(' externalId: string');
811
- lines.push(' email: string');
812
- lines.push(' name?: string');
813
- lines.push(' role?: string');
814
- lines.push(' tenantId?: string');
815
- lines.push(' credential: { token: string; expiresAt?: number; tenantId?: string }');
816
- lines.push('}');
817
- lines.push('');
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
- lines.push(' if (credentials.email) body.email = credentials.email');
842
+ fieldLines.push(` if (credentials.email ?? login) fields[${fieldName('email')}] = (credentials.email ?? login)!`);
868
843
  }
869
844
  if (delegated.credentials.includes('password')) {
870
- lines.push(' if (credentials.password) body.password = credentials.password');
845
+ fieldLines.push(` if (credentials.password) fields[${fieldName('password')}] = credentials.password`);
871
846
  }
872
847
  if (delegated.credentials.includes('apiKey')) {
873
- lines.push(` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`);
874
- }
875
- lines.push('');
876
- lines.push(` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`);
877
- lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`);
878
- lines.push(' headers,');
879
- lines.push(' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,');
880
- lines.push(' })');
881
- lines.push(' if (!response.ok) return null');
882
- lines.push('');
883
- lines.push(' const data: unknown = await response.json()');
884
- lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`);
885
- lines.push(' if (!token) return null');
886
- lines.push('');
887
- if (claims.source === 'jwt') {
888
- lines.push(' const claims = decodeJwtPayload(token)');
889
- }
890
- else {
891
- lines.push(' const claims = data');
892
- }
893
- lines.push(' if (!claims) return null');
894
- lines.push('');
895
- lines.push(` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`);
896
- lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`);
897
- lines.push(' if (!externalId || !email) return null');
898
- lines.push('');
899
- if (namePaths.length > 0) {
900
- const parts = namePaths
901
- .map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
902
- .join(', ');
903
- lines.push(` const name = [${parts}]`);
904
- lines.push(' .filter((part): part is string => Boolean(part))');
905
- lines.push(" .join(' ') || undefined");
906
- }
907
- else {
908
- lines.push(' const name = undefined');
909
- }
910
- if (claims.role) {
911
- lines.push(` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`);
912
- }
913
- else {
914
- lines.push(' const role = undefined');
915
- }
916
- if (claims.tenantId) {
917
- lines.push(` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`);
918
- }
919
- else {
920
- lines.push(' const tenantId = undefined');
921
- }
922
- if (delegated.expiresAtPath) {
923
- lines.push(` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`);
924
- }
925
- else if (claims.source === 'jwt') {
926
- lines.push(" const expiresAtRaw = pick(claims, 'exp')");
927
- }
928
- else {
929
- 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
+ }
930
1032
  }
931
- lines.push(" const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined");
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 lines = [];
949
- // Always import all error classes used in the switch statement
950
- const allErrorClasses = new Set(Object.values(STATUS_TO_ERROR));
951
- if (flags.credential && flags.credential !== 'oauth2') {
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[key] = route;
982
- }
983
- lines.push(`const ROUTES: Record<string, { path: string[], query: string[], headers: string[], errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}`);
984
- lines.push('');
985
- if (flags.camelCase) {
986
- lines.push('function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {');
987
- lines.push(' return Object.fromEntries(');
988
- lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])');
989
- lines.push(' )');
990
- lines.push('}');
991
- lines.push('');
992
- lines.push('function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {');
993
- lines.push(' return Object.fromEntries(');
994
- lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])');
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
- lines.push(` if (${dataVar} && route) {`);
1052
- lines.push(' // Interpolate path params');
1053
- lines.push(' for (const param of route.path) {');
1054
- lines.push(` if (${dataVar}[param] !== undefined) {`);
1055
- lines.push(` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`);
1056
- lines.push(' }');
1057
- lines.push(' }');
1058
- lines.push(' // Extract query params');
1059
- lines.push(' for (const param of route.query) {');
1060
- lines.push(` if (${dataVar}[param] !== undefined) {`);
1061
- lines.push(` query[param] = String(${dataVar}[param])`);
1062
- lines.push(' }');
1063
- lines.push(' }');
1064
- lines.push(' // Extract header params');
1065
- lines.push(' for (const param of route.headers) {');
1066
- lines.push(` if (${dataVar}[param] !== undefined) {`);
1067
- lines.push(` headers[param] = String(${dataVar}[param])`);
1068
- lines.push(' }');
1069
- lines.push(' }');
1070
- lines.push(' // Everything else goes into body');
1071
- lines.push(' const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])');
1072
- lines.push(' const remaining = Object.fromEntries(');
1073
- lines.push(` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`);
1074
- lines.push(' )');
1075
- lines.push(' if (Object.keys(remaining).length > 0) {');
1076
- lines.push(' body = remaining');
1077
- lines.push(' }');
1078
- lines.push(' }');
1079
- lines.push('');
1080
- lines.push(' const url = new URL(`${this.baseUrl}${endpoint}`)');
1081
- lines.push(' for (const [key, value] of Object.entries(query)) {');
1082
- lines.push(' url.searchParams.set(key, value)');
1083
- lines.push(' }');
1084
- lines.push('');
1085
- if (flags.credential && flags.credential !== 'oauth2') {
1086
- // Per-user credential: use creds from wire.getCredentials()
1087
- if (flags.credential === 'bearer') {
1088
- if (flags.authConfig?.headerName) {
1089
- // Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
1090
- const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.token');
1091
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
1092
- }
1093
- else {
1094
- lines.push(' headers.Authorization = `Bearer ${this.creds.token}`');
1095
- }
1096
- }
1097
- else {
1098
- // apikey: auth-config override wins, else check spec for custom header name
1099
- const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
1100
- if (flags.authConfig?.headerName) {
1101
- const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
1102
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
1103
- }
1104
- else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
1105
- lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
1106
- }
1107
- else {
1108
- lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`');
1109
- }
1110
- }
1111
- lines.push('');
1112
- lines.push(' const response = await fetch(url.toString(), {');
1113
- lines.push(' method,');
1114
- lines.push(' headers,');
1115
- lines.push(' body: body ? JSON.stringify(body) : undefined,');
1116
- lines.push(' })');
1117
- }
1118
- else if (flags.oauth || flags.credential === 'oauth2') {
1119
- lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`');
1120
- lines.push('');
1121
- lines.push(' const response = await fetch(url.toString(), {');
1122
- lines.push(' method,');
1123
- lines.push(' headers,');
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
- else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
1135
- lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
1150
+ }
1151
+ for (const param of route.query) {
1152
+ if (${dataVar}[param] !== undefined) {
1153
+ query[param] = String(${dataVar}[param])
1136
1154
  }
1137
- else {
1138
- lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`');
1155
+ }
1156
+ for (const param of route.headers) {
1157
+ if (${dataVar}[param] !== undefined) {
1158
+ headers[param] = String(${dataVar}[param])
1139
1159
  }
1140
- lines.push('');
1141
- lines.push(' const response = await fetch(url.toString(), {');
1142
- lines.push(' method,');
1143
- lines.push(' headers,');
1144
- lines.push(' body: body ? JSON.stringify(body) : undefined,');
1145
- lines.push(' })');
1146
- }
1147
- else {
1148
- lines.push(' const response = await fetch(url.toString(), {');
1149
- lines.push(' method,');
1150
- lines.push(' headers,');
1151
- lines.push(' body: body ? JSON.stringify(body) : undefined,');
1152
- lines.push(' })');
1153
- }
1154
- lines.push('');
1155
- lines.push(' if (!response.ok) {');
1156
- lines.push(' const errorText = await response.text()');
1157
- lines.push(' const errorMessage = route?.errors?.[response.status] ?? errorText');
1158
- lines.push(' switch (response.status) {');
1159
- lines.push(' case 400: throw new BadRequestError(errorMessage)');
1160
- lines.push(' case 401: throw new UnauthorizedError(errorMessage)');
1161
- lines.push(' case 403: throw new ForbiddenError(errorMessage)');
1162
- lines.push(' case 404: throw new NotFoundError(errorMessage)');
1163
- lines.push(' case 405: throw new MethodNotAllowedError(errorMessage)');
1164
- lines.push(' case 409: throw new ConflictError(errorMessage)');
1165
- lines.push(' case 422: throw new UnprocessableContentError(errorMessage)');
1166
- lines.push(' case 429: throw new TooManyRequestsError(errorMessage)');
1167
- lines.push(' case 500: throw new InternalServerError(errorMessage)');
1168
- lines.push(` default: throw new Error(\`${displayName} API error (\${response.status}): \${errorText}\`)`);
1169
- lines.push(' }');
1170
- lines.push(' }');
1171
- lines.push('');
1172
- lines.push(' const text = await response.text()');
1173
- lines.push(' if (!text) return {} as T');
1174
- if (flags.camelCase) {
1175
- lines.push(' const result = JSON.parse(text)');
1176
- lines.push(' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T');
1177
- }
1178
- else {
1179
- lines.push(' return JSON.parse(text) as T');
1180
- }
1181
- lines.push(' }');
1182
- lines.push('}');
1183
- lines.push('');
1184
- 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
+ `;
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.length > 0 ? 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
- if (serverUrls.length > 0) {
1254
- const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ');
1255
- lines.push(`export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`);
1256
- }
1257
- else {
1258
- lines.push(`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`);
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',`);