diff --git a/src/codegen/generators/typescript/channels/openapi.ts b/src/codegen/generators/typescript/channels/openapi.ts index 1fed6596..2099bd9a 100644 --- a/src/codegen/generators/typescript/channels/openapi.ts +++ b/src/codegen/generators/typescript/channels/openapi.ts @@ -21,6 +21,7 @@ import {getMessageTypeAndModule} from './utils'; import {pascalCase} from '../utils'; import {createMissingInputDocumentError} from '../../../errors'; import {resolveImportExtension} from '../../../utils'; +import {extractSecuritySchemes} from '../../../inputs/openapi/security'; type OpenAPIDocument = | OpenAPIV3.Document @@ -75,6 +76,9 @@ export async function generateTypeScriptChannelsForOpenAPI( const {openapiDocument} = validateOpenAPIContext(context); + // Extract security schemes from the OpenAPI document + const securitySchemes = extractSecuritySchemes(openapiDocument); + // Collect dependencies const deps = protocolDependencies['http_client']; const importExtension = resolveImportExtension( @@ -98,8 +102,9 @@ export async function generateTypeScriptChannelsForOpenAPI( ); // Generate common types once (stateless check) + // Pass security schemes to generate only relevant auth types if (protocolCodeFunctions['http_client'].length === 0 && renders.length > 0) { - const commonTypesCode = renderHttpCommonTypes(); + const commonTypesCode = renderHttpCommonTypes(securitySchemes); protocolCodeFunctions['http_client'].unshift(commonTypesCode); } diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 03f1e7ec..24906792 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -1,12 +1,610 @@ import {HttpRenderType} from '../../../../../types'; import {pascalCase} from '../../../utils'; -import {ChannelFunctionTypes, RenderHttpParameters} from '../../types'; +import { + ChannelFunctionTypes, + RenderHttpParameters, + ExtractedSecurityScheme +} from '../../types'; + +// Re-export for use by other modules +export {ExtractedSecurityScheme}; + +/** + * Escapes special characters in strings that will be interpolated into generated code. + * Prevents syntax errors when OpenAPI spec values contain quotes, backticks, or template expressions. + */ +function escapeStringForCodeGen(value: string | undefined): string { + if (!value) { + return ''; + } + return value + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/'/g, "\\'") // Escape single quotes + .replace(/`/g, '\\`') // Escape backticks + .replace(/\$/g, '\\$'); // Escape dollar signs (prevents ${} template evaluation) +} + +/** + * Determines which auth types are needed based on security schemes. + */ +interface AuthTypeRequirements { + bearer: boolean; + basic: boolean; + apiKey: boolean; + oauth2: boolean; + apiKeySchemes: ExtractedSecurityScheme[]; + oauth2Schemes: ExtractedSecurityScheme[]; +} + +/** + * Analyzes security schemes to determine which auth types are needed. + */ +function analyzeSecuritySchemes( + schemes: ExtractedSecurityScheme[] | undefined +): AuthTypeRequirements { + // No schemes = backward compatibility mode, generate all types + if (!schemes || schemes.length === 0) { + return { + bearer: true, + basic: true, + apiKey: true, + oauth2: true, + apiKeySchemes: [], + oauth2Schemes: [] + }; + } + + const requirements: AuthTypeRequirements = { + bearer: false, + basic: false, + apiKey: false, + oauth2: false, + apiKeySchemes: [], + oauth2Schemes: [] + }; + + for (const scheme of schemes) { + switch (scheme.type) { + case 'apiKey': + requirements.apiKey = true; + requirements.apiKeySchemes.push(scheme); + break; + case 'http': + if (scheme.httpScheme === 'bearer') { + requirements.bearer = true; + } else if (scheme.httpScheme === 'basic') { + requirements.basic = true; + } + break; + case 'oauth2': + case 'openIdConnect': + requirements.oauth2 = true; + requirements.oauth2Schemes.push(scheme); + break; + } + } + + return requirements; +} + +/** + * Generates the BearerAuth interface. + */ +function renderBearerAuthInterface(): string { + return `/** + * Bearer token authentication configuration + */ +export interface BearerAuth { + type: 'bearer'; + token: string; +}`; +} + +/** + * Generates the BasicAuth interface. + */ +function renderBasicAuthInterface(): string { + return `/** + * Basic authentication configuration (username/password) + */ +export interface BasicAuth { + type: 'basic'; + username: string; + password: string; +}`; +} + +/** + * Extracts API key defaults from schemes. + * If there's exactly one apiKey scheme, use its values; otherwise use standard defaults. + */ +function getApiKeyDefaults(apiKeySchemes: ExtractedSecurityScheme[]): { + name: string; + in: string; +} { + if (apiKeySchemes.length === 1) { + return { + name: apiKeySchemes[0].apiKeyName || 'X-API-Key', + in: apiKeySchemes[0].apiKeyIn || 'header' + }; + } + return { + name: 'X-API-Key', + in: 'header' + }; +} + +/** + * Generates the ApiKeyAuth interface with optional pre-populated defaults from spec. + */ +function renderApiKeyAuthInterface( + apiKeySchemes: ExtractedSecurityScheme[] +): string { + const defaults = getApiKeyDefaults(apiKeySchemes); + + // For cookie support + const inType = apiKeySchemes.some((s) => s.apiKeyIn === 'cookie') + ? "'header' | 'query' | 'cookie'" + : "'header' | 'query'"; + + // Escape spec values for safe interpolation into generated code + const escapedDefaultName = escapeStringForCodeGen(defaults.name); + const escapedDefaultIn = escapeStringForCodeGen(defaults.in); + + return `/** + * API key authentication configuration + */ +export interface ApiKeyAuth { + type: 'apiKey'; + key: string; + name?: string; // Name of the API key parameter (default: '${escapedDefaultName}') + in?: ${inType}; // Where to place the API key (default: '${escapedDefaultIn}') +}`; +} + +/** + * Extracts the tokenUrl from OAuth2 flows. + */ +function extractTokenUrl( + flows: NonNullable +): string | undefined { + return ( + flows.clientCredentials?.tokenUrl || + flows.password?.tokenUrl || + flows.authorizationCode?.tokenUrl + ); +} + +/** + * Extracts the authorizationUrl from OAuth2 flows. + */ +function extractAuthorizationUrl( + flows: NonNullable +): string | undefined { + return ( + flows.implicit?.authorizationUrl || + flows.authorizationCode?.authorizationUrl + ); +} + +/** + * Collects all scopes from OAuth2 flows. + */ +function collectScopes( + flows: NonNullable +): Set { + const allScopes = new Set(); + const flowTypes = [ + flows.implicit, + flows.password, + flows.clientCredentials, + flows.authorizationCode + ]; + + for (const flow of flowTypes) { + if (flow?.scopes) { + Object.keys(flow.scopes).forEach((s) => allScopes.add(s)); + } + } + + return allScopes; +} + +interface OAuth2DocComments { + tokenUrlComment: string; + authorizationUrlComment: string; + scopesComment: string; +} + +/** + * Formats scopes into a documentation comment. + */ +function formatScopesComment(scopes: Set): string { + if (scopes.size === 0) { + return ''; + } + const scopeList = Array.from(scopes).slice(0, 3).join(', '); + const suffix = scopes.size > 3 ? '...' : ''; + return ` Available: ${scopeList}${suffix}`; +} + +/** + * Extracts documentation comments from a single OAuth2 scheme. + */ +function extractSchemeComments( + scheme: ExtractedSecurityScheme, + existing: OAuth2DocComments +): OAuth2DocComments { + if (scheme.openIdConnectUrl) { + return { + ...existing, + tokenUrlComment: `OpenID Connect URL: '${escapeStringForCodeGen(scheme.openIdConnectUrl)}'` + }; + } + + if (!scheme.oauth2Flows) { + return existing; + } + + const tokenUrl = extractTokenUrl(scheme.oauth2Flows); + const authUrl = extractAuthorizationUrl(scheme.oauth2Flows); + const allScopes = collectScopes(scheme.oauth2Flows); + + return { + tokenUrlComment: tokenUrl + ? `default: '${escapeStringForCodeGen(tokenUrl)}'` + : existing.tokenUrlComment, + authorizationUrlComment: authUrl + ? ` Authorization URL: '${escapeStringForCodeGen(authUrl)}'` + : existing.authorizationUrlComment, + scopesComment: formatScopesComment(allScopes) || existing.scopesComment + }; +} + +/** + * Extracts documentation comments from OAuth2 schemes. + */ +function extractOAuth2DocComments( + oauth2Schemes: ExtractedSecurityScheme[] +): OAuth2DocComments { + const initial: OAuth2DocComments = { + tokenUrlComment: + 'required for client_credentials/password flows and token refresh', + authorizationUrlComment: '', + scopesComment: '' + }; + + return oauth2Schemes.reduce( + (acc, scheme) => extractSchemeComments(scheme, acc), + initial + ); +} + +/** + * Generates the OAuth2Auth interface with optional pre-populated values from spec. + */ +function renderOAuth2AuthInterface( + oauth2Schemes: ExtractedSecurityScheme[] +): string { + const {tokenUrlComment, authorizationUrlComment, scopesComment} = + extractOAuth2DocComments(oauth2Schemes); + + const flowsInfo = authorizationUrlComment + ? `\n *${authorizationUrlComment}` + : ''; + + return `/** + * OAuth2 authentication configuration + * + * Supports server-side flows only: + * - client_credentials: Server-to-server authentication + * - password: Resource owner password credentials (legacy, not recommended) + * - Pre-obtained accessToken: For tokens obtained via browser-based flows + * + * For browser-based flows (implicit, authorization_code), obtain the token + * separately and pass it as accessToken.${flowsInfo} + */ +export interface OAuth2Auth { + type: 'oauth2'; + /** Pre-obtained access token (required if not using a server-side flow) */ + accessToken?: string; + /** Refresh token for automatic token renewal on 401 */ + refreshToken?: string; + /** Token endpoint URL (${tokenUrlComment}) */ + tokenUrl?: string; + /** Client ID (required for flows and token refresh) */ + clientId?: string; + /** Client secret (optional, depends on OAuth provider) */ + clientSecret?: string; + /** Requested scopes${scopesComment} */ + scopes?: string[]; + /** Server-side flow type */ + flow?: 'password' | 'client_credentials'; + /** Username for password flow */ + username?: string; + /** Password for password flow */ + password?: string; + /** Callback when tokens are refreshed (for caching/persistence) */ + onTokenRefresh?: (newTokens: TokenResponse) => void; +}`; +} + +/** + * Generates the AuthConfig union type based on which auth types are needed. + */ +function renderAuthConfigType(requirements: AuthTypeRequirements): string { + const types: string[] = []; + + if (requirements.bearer) { + types.push('BearerAuth'); + } + if (requirements.basic) { + types.push('BasicAuth'); + } + if (requirements.apiKey) { + types.push('ApiKeyAuth'); + } + if (requirements.oauth2) { + types.push('OAuth2Auth'); + } + + // If no types are needed (e.g., no recognized security schemes), don't generate AuthConfig + // The auth field in HttpClientContext is optional, so this is safe + if (types.length === 0) { + return '// No authentication types needed for this API\nexport type AuthConfig = never;'; + } + + return `/** + * Union type for all authentication methods - provides autocomplete support + */ +export type AuthConfig = ${types.join(' | ')};`; +} + +/** + * Generates the security configuration types based on extracted security schemes. + */ +function renderSecurityTypes( + schemes: ExtractedSecurityScheme[] | undefined, + requirements?: AuthTypeRequirements +): string { + const authRequirements = requirements ?? analyzeSecuritySchemes(schemes); + + const parts: string[] = [ + '// ============================================================================', + '// Security Configuration Types - Grouped for better autocomplete', + '// ============================================================================', + '' + ]; + + // Only generate interfaces for required auth types + if (authRequirements.bearer) { + parts.push(renderBearerAuthInterface()); + parts.push(''); + } + + if (authRequirements.basic) { + parts.push(renderBasicAuthInterface()); + parts.push(''); + } + + if (authRequirements.apiKey) { + parts.push(renderApiKeyAuthInterface(authRequirements.apiKeySchemes)); + parts.push(''); + } + + if (authRequirements.oauth2) { + parts.push(renderOAuth2AuthInterface(authRequirements.oauth2Schemes)); + parts.push(''); + } + + // Add the AuthConfig union type + parts.push(renderAuthConfigType(authRequirements)); + + return parts.join('\n'); +} + +/** + * Generate OAuth2 stub functions when OAuth2 is not available. + * These stubs ensure TypeScript compilation succeeds when generated code + * references OAuth2 functions, but the runtime guards prevent them from being called. + */ +function renderOAuth2Stubs(): string { + const code = [ + '', + '// OAuth2 helpers not needed for this API - provide type-safe stubs', + '// These are never called due to AUTH_FEATURES.oauth2 runtime guards', + 'type OAuth2Auth = never;', + 'function validateOAuth2Config(_auth: OAuth2Auth): void {}', + 'async function handleOAuth2TokenFlow(', + ' _auth: OAuth2Auth,', + ' _originalParams: HttpRequestParams,', + ' _makeRequest: (params: HttpRequestParams) => Promise,', + ' _retryConfig?: RetryConfig', + '): Promise { return null; }', + 'async function handleTokenRefresh(', + ' _auth: OAuth2Auth,', + ' _originalParams: HttpRequestParams,', + ' _makeRequest: (params: HttpRequestParams) => Promise,', + ' _retryConfig?: RetryConfig', + '): Promise { return null; }' + ]; + + return code.join('\n'); +} + +/** + * Generates OAuth2-specific helper functions. + * Only included when OAuth2 auth is needed. + */ +function renderOAuth2Helpers(): string { + const code = [ + '', + '/**', + ' * Validate OAuth2 configuration based on flow type', + ' */', + 'function validateOAuth2Config(auth: OAuth2Auth): void {', + ' // If using a flow, validate required fields', + ' switch (auth.flow) {', + ' case \'client_credentials\':', + ' if (!auth.tokenUrl) throw new Error(\'OAuth2 Client Credentials flow requires tokenUrl\');', + ' if (!auth.clientId) throw new Error(\'OAuth2 Client Credentials flow requires clientId\');', + ' break;', + '', + ' case \'password\':', + ' if (!auth.tokenUrl) throw new Error(\'OAuth2 Password flow requires tokenUrl\');', + ' if (!auth.clientId) throw new Error(\'OAuth2 Password flow requires clientId\');', + ' if (!auth.username) throw new Error(\'OAuth2 Password flow requires username\');', + ' if (!auth.password) throw new Error(\'OAuth2 Password flow requires password\');', + ' break;', + '', + ' default:', + ' // No flow specified - must have accessToken for OAuth2 to work', + ' if (!auth.accessToken && !auth.flow) {', + ' // This is fine - token refresh can still work if refreshToken is provided', + ' // Or the request will just be made without auth', + ' }', + ' break;', + ' }', + '}', + '', + '/**', + ' * Handle OAuth2 token flows (client_credentials, password)', + ' */', + 'async function handleOAuth2TokenFlow(', + ' auth: OAuth2Auth,', + ' originalParams: HttpRequestParams,', + ' makeRequest: (params: HttpRequestParams) => Promise,', + ' retryConfig?: RetryConfig', + '): Promise {', + ' if (!auth.flow || !auth.tokenUrl) return null;', + '', + ' const params = new URLSearchParams();', + '', + ' if (auth.flow === \'client_credentials\') {', + ' params.append(\'grant_type\', \'client_credentials\');', + ' params.append(\'client_id\', auth.clientId!);', + ' } else if (auth.flow === \'password\') {', + ' params.append(\'grant_type\', \'password\');', + ' params.append(\'username\', auth.username || \'\');', + ' params.append(\'password\', auth.password || \'\');', + ' params.append(\'client_id\', auth.clientId!);', + ' } else {', + ' return null;', + ' }', + '', + ' if (auth.clientSecret) {', + ' params.append(\'client_secret\', auth.clientSecret);', + ' }', + ' if (auth.scopes && auth.scopes.length > 0) {', + ' params.append(\'scope\', auth.scopes.join(\' \'));', + ' }', + '', + ' const authHeaders: Record = {', + ' \'Content-Type\': \'application/x-www-form-urlencoded\'', + ' };', + '', + ` // Use basic auth for client credentials if both client ID and secret are provided`, + ` if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) {`, + ` const credentials = Buffer.from(\`\${auth.clientId}:\${auth.clientSecret}\`).toString('base64');`, + ` authHeaders['Authorization'] = \`Basic \${credentials}\`;`, + ` params.delete('client_id');`, + ` params.delete('client_secret');`, + ` }`, + ``, + ` const tokenResponse = await NodeFetch.default(auth.tokenUrl, {`, + ` method: 'POST',`, + ` headers: authHeaders,`, + ` body: params.toString()`, + ` });`, + ``, + ` if (!tokenResponse.ok) {`, + ` throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`);`, + ` }`, + '', + ' const tokenData = await tokenResponse.json();', + ' const tokens: TokenResponse = {', + ' accessToken: tokenData.access_token,', + ' refreshToken: tokenData.refresh_token,', + ' expiresIn: tokenData.expires_in', + ' };', + '', + ' // Notify the client about the tokens', + ' if (auth.onTokenRefresh) {', + ' auth.onTokenRefresh(tokens);', + ' }', + '', + ` // Retry the original request with the new token`, + ` const updatedHeaders = { ...originalParams.headers };`, + ` updatedHeaders['Authorization'] = \`Bearer \${tokens.accessToken}\`;`, + ``, + ` return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig);`, + `}`, + '', + '/**', + ' * Handle OAuth2 token refresh on 401 response', + ' */', + 'async function handleTokenRefresh(', + ' auth: OAuth2Auth,', + ' originalParams: HttpRequestParams,', + ' makeRequest: (params: HttpRequestParams) => Promise,', + ' retryConfig?: RetryConfig', + '): Promise {', + ' if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null;', + '', + ' const refreshResponse = await NodeFetch.default(auth.tokenUrl, {', + ' method: \'POST\',', + ' headers: {', + ' \'Content-Type\': \'application/x-www-form-urlencoded\'', + ' },', + ' body: new URLSearchParams({', + ' grant_type: \'refresh_token\',', + ' refresh_token: auth.refreshToken,', + ' client_id: auth.clientId,', + ' ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {})', + ' }).toString()', + ' });', + '', + ' if (!refreshResponse.ok) {', + ' throw new Error(\'Unauthorized\');', + ' }', + '', + ' const tokenData = await refreshResponse.json();', + ' const newTokens: TokenResponse = {', + ' accessToken: tokenData.access_token,', + ' refreshToken: tokenData.refresh_token || auth.refreshToken,', + ' expiresIn: tokenData.expires_in', + ' };', + '', + ' // Notify the client about the refreshed tokens', + ' if (auth.onTokenRefresh) {', + ' auth.onTokenRefresh(newTokens);', + ' }', + '', + ` // Retry the original request with the new token`, + ` const updatedHeaders = { ...originalParams.headers };`, + ` updatedHeaders['Authorization'] = \`Bearer \${newTokens.accessToken}\`;`, + ``, + ` return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig);`, + `}` + ]; + + return code.join('\n'); +} /** * Generates common types and helper functions shared across all HTTP client functions. * This should be called once per protocol generation to avoid code duplication. + * + * @param securitySchemes - Optional security schemes extracted from OpenAPI. + * When provided, only relevant auth types are generated. + * When undefined/empty, all auth types are generated for backward compatibility. */ -export function renderHttpCommonTypes(): string { +export function renderHttpCommonTypes( + securitySchemes?: ExtractedSecurityScheme[] +): string { + const requirements = analyzeSecuritySchemes(securitySchemes); + const securityTypes = renderSecurityTypes(securitySchemes, requirements); + return `// ============================================================================ // Common Types - Shared across all HTTP client functions // ============================================================================ @@ -88,76 +686,24 @@ export interface TokenResponse { expiresIn?: number; } -// ============================================================================ -// Security Configuration Types - Grouped for better autocomplete -// ============================================================================ - -/** - * Bearer token authentication configuration - */ -export interface BearerAuth { - type: 'bearer'; - token: string; -} +${securityTypes} /** - * Basic authentication configuration (username/password) - */ -export interface BasicAuth { - type: 'basic'; - username: string; - password: string; -} - -/** - * API key authentication configuration + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. */ -export interface ApiKeyAuth { - type: 'apiKey'; - key: string; - name?: string; // Name of the API key parameter (default: 'X-API-Key') - in?: 'header' | 'query'; // Where to place the API key (default: 'header') -} - -/** - * OAuth2 authentication configuration - * - * Supports server-side flows only: - * - client_credentials: Server-to-server authentication - * - password: Resource owner password credentials (legacy, not recommended) - * - Pre-obtained accessToken: For tokens obtained via browser-based flows - * - * For browser-based flows (implicit, authorization_code), obtain the token - * separately and pass it as accessToken. - */ -export interface OAuth2Auth { - type: 'oauth2'; - /** Pre-obtained access token (required if not using a server-side flow) */ - accessToken?: string; - /** Refresh token for automatic token renewal on 401 */ - refreshToken?: string; - /** Token endpoint URL (required for client_credentials/password flows and token refresh) */ - tokenUrl?: string; - /** Client ID (required for flows and token refresh) */ - clientId?: string; - /** Client secret (optional, depends on OAuth provider) */ - clientSecret?: string; - /** Requested scopes */ - scopes?: string[]; - /** Server-side flow type */ - flow?: 'password' | 'client_credentials'; - /** Username for password flow */ - username?: string; - /** Password for password flow */ - password?: string; - /** Callback when tokens are refreshed (for caching/persistence) */ - onTokenRefresh?: (newTokens: TokenResponse) => void; -} +const AUTH_FEATURES = { + oauth2: ${requirements.oauth2} +} as const; /** - * Union type for all authentication methods - provides autocomplete support + * Default values for API key authentication derived from the spec. + * These match the defaults documented in the ApiKeyAuth interface. */ -export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; +const API_KEY_DEFAULTS = { + name: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).name)}', + in: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).in)}' as 'header' | 'query' | 'cookie' +} as const; // ============================================================================ // Pagination Types @@ -351,14 +897,16 @@ function applyAuth( } case 'apiKey': { - const keyName = auth.name ?? 'X-API-Key'; - const keyIn = auth.in ?? 'header'; + const keyName = auth.name ?? API_KEY_DEFAULTS.name; + const keyIn = auth.in ?? API_KEY_DEFAULTS.in; if (keyIn === 'header') { headers[keyName] = auth.key; - } else { + } else if (keyIn === 'query') { const separator = url.includes('?') ? '&' : '?'; url = \`\${url}\${separator}\${keyName}=\${encodeURIComponent(auth.key)}\`; + } else if (keyIn === 'cookie') { + headers['Cookie'] = \`\${keyName}=\${auth.key}\`; } break; } @@ -376,34 +924,6 @@ function applyAuth( return { headers, url }; } -/** - * Validate OAuth2 configuration based on flow type - */ -function validateOAuth2Config(auth: OAuth2Auth): void { - // If using a flow, validate required fields - switch (auth.flow) { - case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); - break; - - case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); - break; - - default: - // No flow specified - must have accessToken for OAuth2 to work - if (!auth.accessToken && !auth.flow) { - // This is fine - token refresh can still work if refreshToken is provided - // Or the request will just be made without auth - } - break; - } -} - /** * Apply pagination parameters to URL and/or headers based on configuration */ @@ -593,126 +1113,6 @@ async function executeWithRetry( throw lastError ?? new Error('Request failed after retries'); } -/** - * Handle OAuth2 token flows (client_credentials, password) - */ -async function handleOAuth2TokenFlow( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.flow || !auth.tokenUrl) return null; - - const params = new URLSearchParams(); - - if (auth.flow === 'client_credentials') { - params.append('grant_type', 'client_credentials'); - params.append('client_id', auth.clientId!); - } else if (auth.flow === 'password') { - params.append('grant_type', 'password'); - params.append('username', auth.username || ''); - params.append('password', auth.password || ''); - params.append('client_id', auth.clientId!); - } else { - return null; - } - - if (auth.clientSecret) { - params.append('client_secret', auth.clientSecret); - } - if (auth.scopes && auth.scopes.length > 0) { - params.append('scope', auth.scopes.join(' ')); - } - - const authHeaders: Record = { - 'Content-Type': 'application/x-www-form-urlencoded' - }; - - // Use basic auth for client credentials if both client ID and secret are provided - if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { - const credentials = Buffer.from(\`\${auth.clientId}:\${auth.clientSecret}\`).toString('base64'); - authHeaders['Authorization'] = \`Basic \${credentials}\`; - params.delete('client_id'); - params.delete('client_secret'); - } - - const tokenResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: authHeaders, - body: params.toString() - }); - - if (!tokenResponse.ok) { - throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); - } - - const tokenData = await tokenResponse.json(); - const tokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(tokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = \`Bearer \${tokens.accessToken}\`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - -/** - * Handle OAuth2 token refresh on 401 response - */ -async function handleTokenRefresh( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; - - const refreshResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: auth.refreshToken, - client_id: auth.clientId, - ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) - }).toString() - }); - - if (!refreshResponse.ok) { - throw new Error('Unauthorized'); - } - - const tokenData = await refreshResponse.json(); - const newTokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || auth.refreshToken, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the refreshed tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(newTokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = \`Bearer \${newTokens.accessToken}\`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - /** * Handle HTTP error status codes with standardized messages */ @@ -984,7 +1384,7 @@ function applyTypedHeaders( return headers; } - +${requirements.oauth2 ? renderOAuth2Helpers() : renderOAuth2Stubs()} // ============================================================================ // Generated HTTP Client Functions // ============================================================================`; @@ -1160,7 +1560,7 @@ function generateFunctionImplementation(params: { }; // Validate OAuth2 config if present - if (config.auth?.type === 'oauth2') { + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { validateOAuth2Config(config.auth); } @@ -1210,7 +1610,7 @@ function generateFunctionImplementation(params: { } // Handle OAuth2 token flows that require getting a token first - if (config.auth?.type === 'oauth2' && !config.auth.accessToken) { + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); if (tokenFlowResponse) { response = tokenFlowResponse; @@ -1218,7 +1618,7 @@ function generateFunctionImplementation(params: { } // Handle 401 with token refresh - if (response.status === 401 && config.auth?.type === 'oauth2') { + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { try { const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); if (refreshResponse) { diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 234bb18b..d30fd6b6 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -6,6 +6,10 @@ import {TypeScriptPayloadRenderType} from '../payloads'; import {TypeScriptParameterRenderType} from '../parameters'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; +import {ExtractedSecurityScheme} from '../../../inputs/openapi/security'; + +// Re-export for convenience +export {ExtractedSecurityScheme}; export enum ChannelFunctionTypes { NATS_JETSTREAM_PUBLISH = 'nats_jetstream_publish', diff --git a/src/codegen/inputs/openapi/security.ts b/src/codegen/inputs/openapi/security.ts new file mode 100644 index 00000000..0a40aa46 --- /dev/null +++ b/src/codegen/inputs/openapi/security.ts @@ -0,0 +1,295 @@ +/** + * Extracts security scheme information from OpenAPI 2.0/3.x documents. + * Converts security definitions to a normalized internal format. + */ +import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; + +/** + * Normalized security scheme extracted from OpenAPI documents. + * Supports OpenAPI 3.x securitySchemes and Swagger 2.0 securityDefinitions. + */ +export interface ExtractedSecurityScheme { + /** The name/key of the security scheme as defined in the spec */ + name: string; + /** Security scheme type */ + type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect'; + + /** For apiKey type: the name of the key parameter */ + apiKeyName?: string; + /** For apiKey type: where to place the key */ + apiKeyIn?: 'header' | 'query' | 'cookie'; + + /** For http type: the authentication scheme (bearer, basic, etc.) */ + httpScheme?: 'bearer' | 'basic' | string; + /** For http bearer: the format of the bearer token */ + bearerFormat?: string; + + /** For oauth2 type: the available flows */ + oauth2Flows?: { + implicit?: { + authorizationUrl: string; + scopes: Record; + }; + password?: { + tokenUrl: string; + scopes: Record; + }; + clientCredentials?: { + tokenUrl: string; + scopes: Record; + }; + authorizationCode?: { + authorizationUrl: string; + tokenUrl: string; + scopes: Record; + }; + }; + + /** For openIdConnect type: the OpenID Connect discovery URL */ + openIdConnectUrl?: string; +} + +type OpenAPIDocument = + | OpenAPIV3.Document + | OpenAPIV2.Document + | OpenAPIV3_1.Document; + +/** + * Extracts security schemes from an OpenAPI document. + * Handles both OpenAPI 3.x (components.securitySchemes) and + * Swagger 2.0 (securityDefinitions) formats. + */ +export function extractSecuritySchemes( + document: OpenAPIDocument +): ExtractedSecurityScheme[] { + // Check if OpenAPI 3.x document + if ('openapi' in document) { + return extractOpenAPI3SecuritySchemes( + document as OpenAPIV3.Document | OpenAPIV3_1.Document + ); + } + + // Check if Swagger 2.0 document + if ('swagger' in document) { + return extractSwagger2SecuritySchemes(document as OpenAPIV2.Document); + } + + return []; +} + +/** + * Extracts security schemes from OpenAPI 3.x documents. + */ +function extractOpenAPI3SecuritySchemes( + document: OpenAPIV3.Document | OpenAPIV3_1.Document +): ExtractedSecurityScheme[] { + const securitySchemes = document.components?.securitySchemes; + if (!securitySchemes) { + return []; + } + + const schemes: ExtractedSecurityScheme[] = []; + + for (const [name, scheme] of Object.entries(securitySchemes)) { + // Skip $ref - should be dereferenced already + if ('$ref' in scheme) { + continue; + } + + const securityScheme = scheme as OpenAPIV3.SecuritySchemeObject; + const extracted = extractOpenAPI3Scheme(name, securityScheme); + if (extracted) { + schemes.push(extracted); + } + } + + return schemes; +} + +/** + * Extracts a single OpenAPI 3.x security scheme. + */ +function extractOpenAPI3Scheme( + name: string, + scheme: OpenAPIV3.SecuritySchemeObject +): ExtractedSecurityScheme | undefined { + switch (scheme.type) { + case 'apiKey': + return { + name, + type: 'apiKey', + apiKeyName: scheme.name, + apiKeyIn: scheme.in as 'header' | 'query' | 'cookie' + }; + + case 'http': + return { + name, + type: 'http', + httpScheme: scheme.scheme as 'bearer' | 'basic', + bearerFormat: scheme.bearerFormat + }; + + case 'oauth2': + return { + name, + type: 'oauth2', + oauth2Flows: extractOAuth2Flows(scheme.flows) + }; + + case 'openIdConnect': + return { + name, + type: 'openIdConnect', + openIdConnectUrl: scheme.openIdConnectUrl + }; + + default: + return undefined; + } +} + +/** + * Extracts OAuth2 flows from OpenAPI 3.x OAuth2 security scheme. + */ +function extractOAuth2Flows( + flows: OpenAPIV3.OAuth2SecurityScheme['flows'] +): ExtractedSecurityScheme['oauth2Flows'] { + const result: ExtractedSecurityScheme['oauth2Flows'] = {}; + + if (flows.implicit) { + result.implicit = { + authorizationUrl: flows.implicit.authorizationUrl, + scopes: flows.implicit.scopes || {} + }; + } + + if (flows.password) { + result.password = { + tokenUrl: flows.password.tokenUrl, + scopes: flows.password.scopes || {} + }; + } + + if (flows.clientCredentials) { + result.clientCredentials = { + tokenUrl: flows.clientCredentials.tokenUrl, + scopes: flows.clientCredentials.scopes || {} + }; + } + + if (flows.authorizationCode) { + result.authorizationCode = { + authorizationUrl: flows.authorizationCode.authorizationUrl, + tokenUrl: flows.authorizationCode.tokenUrl, + scopes: flows.authorizationCode.scopes || {} + }; + } + + return result; +} + +/** + * Extracts security definitions from Swagger 2.0 documents. + */ +function extractSwagger2SecuritySchemes( + document: OpenAPIV2.Document +): ExtractedSecurityScheme[] { + const securityDefinitions = document.securityDefinitions; + if (!securityDefinitions) { + return []; + } + + const schemes: ExtractedSecurityScheme[] = []; + + for (const [name, definition] of Object.entries(securityDefinitions)) { + const extracted = extractSwagger2Scheme(name, definition); + if (extracted) { + schemes.push(extracted); + } + } + + return schemes; +} + +/** + * Extracts a single Swagger 2.0 security definition. + */ +function extractSwagger2Scheme( + name: string, + definition: OpenAPIV2.SecuritySchemeObject +): ExtractedSecurityScheme | undefined { + switch (definition.type) { + case 'apiKey': + return { + name, + type: 'apiKey', + apiKeyName: definition.name, + apiKeyIn: definition.in as 'header' | 'query' + }; + + case 'basic': + // Swagger 2.0 has a separate 'basic' type which maps to http basic + return { + name, + type: 'http', + httpScheme: 'basic' + }; + + case 'oauth2': + return { + name, + type: 'oauth2', + oauth2Flows: extractSwagger2OAuth2Flow(definition) + }; + + default: + return undefined; + } +} + +/** + * Extracts OAuth2 flow from Swagger 2.0 format. + * Swagger 2.0 uses single 'flow' field instead of 'flows' object. + */ +function extractSwagger2OAuth2Flow( + definition: OpenAPIV2.SecuritySchemeOauth2 +): ExtractedSecurityScheme['oauth2Flows'] { + const result: ExtractedSecurityScheme['oauth2Flows'] = {}; + const scopes = definition.scopes || {}; + + switch (definition.flow) { + case 'implicit': + result.implicit = { + authorizationUrl: definition.authorizationUrl || '', + scopes + }; + break; + + case 'password': + result.password = { + tokenUrl: definition.tokenUrl || '', + scopes + }; + break; + + case 'application': + // Swagger 2.0 'application' maps to OpenAPI 3.x 'clientCredentials' + result.clientCredentials = { + tokenUrl: definition.tokenUrl || '', + scopes + }; + break; + + case 'accessCode': + // Swagger 2.0 'accessCode' maps to OpenAPI 3.x 'authorizationCode' + result.authorizationCode = { + authorizationUrl: definition.authorizationUrl || '', + tokenUrl: definition.tokenUrl || '', + scopes + }; + break; + } + + return result; +} diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index 52a490e3..5eab2593 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -99,30 +99,13 @@ export interface TokenResponse { // Security Configuration Types - Grouped for better autocomplete // ============================================================================ -/** - * Bearer token authentication configuration - */ -export interface BearerAuth { - type: 'bearer'; - token: string; -} - -/** - * Basic authentication configuration (username/password) - */ -export interface BasicAuth { - type: 'basic'; - username: string; - password: string; -} - /** * API key authentication configuration */ export interface ApiKeyAuth { type: 'apiKey'; key: string; - name?: string; // Name of the API key parameter (default: 'X-API-Key') + name?: string; // Name of the API key parameter (default: 'api_key') in?: 'header' | 'query'; // Where to place the API key (default: 'header') } @@ -136,6 +119,7 @@ export interface ApiKeyAuth { * * For browser-based flows (implicit, authorization_code), obtain the token * separately and pass it as accessToken. + * Authorization URL: 'http://petstore.swagger.io/api/oauth/dialog' */ export interface OAuth2Auth { type: 'oauth2'; @@ -149,7 +133,7 @@ export interface OAuth2Auth { clientId?: string; /** Client secret (optional, depends on OAuth provider) */ clientSecret?: string; - /** Requested scopes */ + /** Requested scopes Available: write:pets, read:pets */ scopes?: string[]; /** Server-side flow type */ flow?: 'password' | 'client_credentials'; @@ -164,7 +148,24 @@ export interface OAuth2Auth { /** * Union type for all authentication methods - provides autocomplete support */ -export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; +export type AuthConfig = ApiKeyAuth | OAuth2Auth; + +/** + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. + */ +const AUTH_FEATURES = { + oauth2: true +} as const; + +/** + * Default values for API key authentication derived from the spec. + * These match the defaults documented in the ApiKeyAuth interface. + */ +const API_KEY_DEFAULTS = { + name: 'api_key', + in: 'header' as 'header' | 'query' | 'cookie' +} as const; // ============================================================================ // Pagination Types @@ -358,14 +359,16 @@ function applyAuth( } case 'apiKey': { - const keyName = auth.name ?? 'X-API-Key'; - const keyIn = auth.in ?? 'header'; + const keyName = auth.name ?? API_KEY_DEFAULTS.name; + const keyIn = auth.in ?? API_KEY_DEFAULTS.in; if (keyIn === 'header') { headers[keyName] = auth.key; - } else { + } else if (keyIn === 'query') { const separator = url.includes('?') ? '&' : '?'; url = \`\${url}\${separator}\${keyName}=\${encodeURIComponent(auth.key)}\`; + } else if (keyIn === 'cookie') { + headers['Cookie'] = \`\${keyName}=\${auth.key}\`; } break; } @@ -383,34 +386,6 @@ function applyAuth( return { headers, url }; } -/** - * Validate OAuth2 configuration based on flow type - */ -function validateOAuth2Config(auth: OAuth2Auth): void { - // If using a flow, validate required fields - switch (auth.flow) { - case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); - break; - - case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); - break; - - default: - // No flow specified - must have accessToken for OAuth2 to work - if (!auth.accessToken && !auth.flow) { - // This is fine - token refresh can still work if refreshToken is provided - // Or the request will just be made without auth - } - break; - } -} - /** * Apply pagination parameters to URL and/or headers based on configuration */ @@ -600,126 +575,6 @@ async function executeWithRetry( throw lastError ?? new Error('Request failed after retries'); } -/** - * Handle OAuth2 token flows (client_credentials, password) - */ -async function handleOAuth2TokenFlow( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.flow || !auth.tokenUrl) return null; - - const params = new URLSearchParams(); - - if (auth.flow === 'client_credentials') { - params.append('grant_type', 'client_credentials'); - params.append('client_id', auth.clientId!); - } else if (auth.flow === 'password') { - params.append('grant_type', 'password'); - params.append('username', auth.username || ''); - params.append('password', auth.password || ''); - params.append('client_id', auth.clientId!); - } else { - return null; - } - - if (auth.clientSecret) { - params.append('client_secret', auth.clientSecret); - } - if (auth.scopes && auth.scopes.length > 0) { - params.append('scope', auth.scopes.join(' ')); - } - - const authHeaders: Record = { - 'Content-Type': 'application/x-www-form-urlencoded' - }; - - // Use basic auth for client credentials if both client ID and secret are provided - if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { - const credentials = Buffer.from(\`\${auth.clientId}:\${auth.clientSecret}\`).toString('base64'); - authHeaders['Authorization'] = \`Basic \${credentials}\`; - params.delete('client_id'); - params.delete('client_secret'); - } - - const tokenResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: authHeaders, - body: params.toString() - }); - - if (!tokenResponse.ok) { - throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); - } - - const tokenData = await tokenResponse.json(); - const tokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(tokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = \`Bearer \${tokens.accessToken}\`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - -/** - * Handle OAuth2 token refresh on 401 response - */ -async function handleTokenRefresh( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; - - const refreshResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: auth.refreshToken, - client_id: auth.clientId, - ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) - }).toString() - }); - - if (!refreshResponse.ok) { - throw new Error('Unauthorized'); - } - - const tokenData = await refreshResponse.json(); - const newTokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || auth.refreshToken, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the refreshed tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(newTokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = \`Bearer \${newTokens.accessToken}\`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - /** * Handle HTTP error status codes with standardized messages */ @@ -992,41 +847,188 @@ function applyTypedHeaders( return headers; } -// ============================================================================ -// Generated HTTP Client Functions -// ============================================================================ +/** + * Validate OAuth2 configuration based on flow type + */ +function validateOAuth2Config(auth: OAuth2Auth): void { + // If using a flow, validate required fields + switch (auth.flow) { + case 'client_credentials': + if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + break; -export interface PostAddPetContext extends HttpClientContext { - payload: Pet; - requestHeaders?: { marshal: () => string }; + case 'password': + if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new Error('OAuth2 Password flow requires username'); + if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + break; + + default: + // No flow specified - must have accessToken for OAuth2 to work + if (!auth.accessToken && !auth.flow) { + // This is fine - token refresh can still work if refreshToken is provided + // Or the request will just be made without auth + } + break; + } } -async function postAddPet(context: PostAddPetContext): Promise> { - // Apply defaults - const config = { - path: '/pet', - server: 'localhost:3000', - ...context, - }; +/** + * Handle OAuth2 token flows (client_credentials, password) + */ +async function handleOAuth2TokenFlow( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.flow || !auth.tokenUrl) return null; - // Validate OAuth2 config if present - if (config.auth?.type === 'oauth2') { - validateOAuth2Config(config.auth); + const params = new URLSearchParams(); + + if (auth.flow === 'client_credentials') { + params.append('grant_type', 'client_credentials'); + params.append('client_id', auth.clientId!); + } else if (auth.flow === 'password') { + params.append('grant_type', 'password'); + params.append('username', auth.username || ''); + params.append('password', auth.password || ''); + params.append('client_id', auth.clientId!); + } else { + return null; } - // Build headers - let headers = context.requestHeaders - ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) - : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } - // Build URL - let url = \`\${config.server}\${config.path}\`; - url = applyQueryParams(config.queryParams, url); + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; - // Apply pagination (can affect URL and/or headers) - const paginationResult = applyPagination(config.pagination, url, headers); - url = paginationResult.url; - headers = paginationResult.headers; + // Use basic auth for client credentials if both client ID and secret are provided + if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { + const credentials = Buffer.from(\`\${auth.clientId}:\${auth.clientSecret}\`).toString('base64'); + authHeaders['Authorization'] = \`Basic \${credentials}\`; + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (!tokenResponse.ok) { + throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); + } + + const tokenData = await tokenResponse.json(); + const tokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = \`Bearer \${tokens.accessToken}\`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} + +/** + * Handle OAuth2 token refresh on 401 response + */ +async function handleTokenRefresh( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; + + const refreshResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) + }).toString() + }); + + if (!refreshResponse.ok) { + throw new Error('Unauthorized'); + } + + const tokenData = await refreshResponse.json(); + const newTokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || auth.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the refreshed tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = \`Bearer \${newTokens.accessToken}\`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} +// ============================================================================ +// Generated HTTP Client Functions +// ============================================================================ + +export interface PostAddPetContext extends HttpClientContext { + payload: Pet; + requestHeaders?: { marshal: () => string }; +} + +async function postAddPet(context: PostAddPetContext): Promise> { + // Apply defaults + const config = { + path: '/pet', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = \`\${config.server}\${config.path}\`; + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; // Apply authentication const authResult = applyAuth(config.auth, headers, url); @@ -1062,7 +1064,7 @@ async function postAddPet(context: PostAddPetContext): Promise Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.flow || !auth.tokenUrl) return null; - - const params = new URLSearchParams(); - - if (auth.flow === 'client_credentials') { - params.append('grant_type', 'client_credentials'); - params.append('client_id', auth.clientId!); - } else if (auth.flow === 'password') { - params.append('grant_type', 'password'); - params.append('username', auth.username || ''); - params.append('password', auth.password || ''); - params.append('client_id', auth.clientId!); - } else { - return null; - } - - if (auth.clientSecret) { - params.append('client_secret', auth.clientSecret); - } - if (auth.scopes && auth.scopes.length > 0) { - params.append('scope', auth.scopes.join(' ')); - } - - const authHeaders: Record = { - 'Content-Type': 'application/x-www-form-urlencoded' - }; - - // Use basic auth for client credentials if both client ID and secret are provided - if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { - const credentials = Buffer.from(\`\${auth.clientId}:\${auth.clientSecret}\`).toString('base64'); - authHeaders['Authorization'] = \`Basic \${credentials}\`; - params.delete('client_id'); - params.delete('client_secret'); - } - - const tokenResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: authHeaders, - body: params.toString() - }); - - if (!tokenResponse.ok) { - throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); - } - - const tokenData = await tokenResponse.json(); - const tokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(tokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = \`Bearer \${tokens.accessToken}\`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - -/** - * Handle OAuth2 token refresh on 401 response - */ -async function handleTokenRefresh( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; - - const refreshResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: auth.refreshToken, - client_id: auth.clientId, - ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) - }).toString() - }); - - if (!refreshResponse.ok) { - throw new Error('Unauthorized'); - } - - const tokenData = await refreshResponse.json(); - const newTokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || auth.refreshToken, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the refreshed tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(newTokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = \`Bearer \${newTokens.accessToken}\`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - /** * Handle HTTP error status codes with standardized messages */ @@ -2889,6 +2762,153 @@ function applyTypedHeaders( return headers; } +/** + * Validate OAuth2 configuration based on flow type + */ +function validateOAuth2Config(auth: OAuth2Auth): void { + // If using a flow, validate required fields + switch (auth.flow) { + case 'client_credentials': + if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + break; + + case 'password': + if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new Error('OAuth2 Password flow requires username'); + if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + break; + + default: + // No flow specified - must have accessToken for OAuth2 to work + if (!auth.accessToken && !auth.flow) { + // This is fine - token refresh can still work if refreshToken is provided + // Or the request will just be made without auth + } + break; + } +} + +/** + * Handle OAuth2 token flows (client_credentials, password) + */ +async function handleOAuth2TokenFlow( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.flow || !auth.tokenUrl) return null; + + const params = new URLSearchParams(); + + if (auth.flow === 'client_credentials') { + params.append('grant_type', 'client_credentials'); + params.append('client_id', auth.clientId!); + } else if (auth.flow === 'password') { + params.append('grant_type', 'password'); + params.append('username', auth.username || ''); + params.append('password', auth.password || ''); + params.append('client_id', auth.clientId!); + } else { + return null; + } + + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } + + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // Use basic auth for client credentials if both client ID and secret are provided + if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { + const credentials = Buffer.from(\`\${auth.clientId}:\${auth.clientSecret}\`).toString('base64'); + authHeaders['Authorization'] = \`Basic \${credentials}\`; + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (!tokenResponse.ok) { + throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); + } + + const tokenData = await tokenResponse.json(); + const tokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = \`Bearer \${tokens.accessToken}\`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} + +/** + * Handle OAuth2 token refresh on 401 response + */ +async function handleTokenRefresh( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; + + const refreshResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) + }).toString() + }); + + if (!refreshResponse.ok) { + throw new Error('Unauthorized'); + } + + const tokenData = await refreshResponse.json(); + const newTokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || auth.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the refreshed tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = \`Bearer \${newTokens.accessToken}\`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} // ============================================================================ // Generated HTTP Client Functions // ============================================================================ @@ -2906,7 +2926,7 @@ async function getPingRequest(context: GetPingRequestContext = {}): Promise { + describe('renderHttpCommonTypes with security schemes', () => { + it('should generate only apiKey auth type when only apiKey scheme defined', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'api_key', + type: 'apiKey', + apiKeyName: 'X-API-Key', + apiKeyIn: 'header' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + // Should include ApiKeyAuth interface + expect(result).toContain('export interface ApiKeyAuth'); + expect(result).toContain("type: 'apiKey'"); + + // Should include pre-populated values from the spec + expect(result).toContain('X-API-Key'); + expect(result).toContain('header'); + + // Should NOT include other auth types + expect(result).not.toContain('export interface BearerAuth'); + expect(result).not.toContain('export interface BasicAuth'); + expect(result).not.toContain('export interface OAuth2Auth'); + + // AuthConfig should only contain ApiKeyAuth + expect(result).toContain('export type AuthConfig = ApiKeyAuth'); + }); + + it('should generate only bearer auth type when http bearer scheme defined', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'bearerAuth', + type: 'http', + httpScheme: 'bearer' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + expect(result).toContain('export interface BearerAuth'); + expect(result).toContain("type: 'bearer'"); + expect(result).not.toContain('export interface ApiKeyAuth'); + expect(result).not.toContain('export interface BasicAuth'); + expect(result).not.toContain('export interface OAuth2Auth'); + expect(result).toContain('export type AuthConfig = BearerAuth'); + }); + + it('should generate only basic auth type when http basic scheme defined', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'basicAuth', + type: 'http', + httpScheme: 'basic' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + expect(result).toContain('export interface BasicAuth'); + expect(result).toContain("type: 'basic'"); + expect(result).not.toContain('export interface BearerAuth'); + expect(result).not.toContain('export interface ApiKeyAuth'); + expect(result).not.toContain('export interface OAuth2Auth'); + expect(result).toContain('export type AuthConfig = BasicAuth'); + }); + + it('should generate only oauth2 auth type when oauth2 scheme defined', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'oauth2', + type: 'oauth2', + oauth2Flows: { + clientCredentials: { + tokenUrl: 'https://example.com/token', + scopes: {read: 'read access'} + } + } + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + expect(result).toContain('export interface OAuth2Auth'); + expect(result).toContain("type: 'oauth2'"); + + // Should include pre-populated tokenUrl from the spec + expect(result).toContain('https://example.com/token'); + + expect(result).not.toContain('export interface BearerAuth'); + expect(result).not.toContain('export interface BasicAuth'); + expect(result).not.toContain('export interface ApiKeyAuth'); + expect(result).toContain('export type AuthConfig = OAuth2Auth'); + }); + + it('should generate multiple auth types when multiple schemes defined', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'api_key', + type: 'apiKey', + apiKeyName: 'api_key', + apiKeyIn: 'header' + }, + { + name: 'bearerAuth', + type: 'http', + httpScheme: 'bearer' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + expect(result).toContain('export interface ApiKeyAuth'); + expect(result).toContain('export interface BearerAuth'); + expect(result).not.toContain('export interface BasicAuth'); + expect(result).not.toContain('export interface OAuth2Auth'); + + // AuthConfig should be a union of the defined types + expect(result).toMatch(/export type AuthConfig = (?:ApiKeyAuth \| BearerAuth|BearerAuth \| ApiKeyAuth)/); + }); + + it('should generate all auth types when no security schemes defined (backward compatibility)', () => { + const result = renderHttpCommonTypes(); // No argument - backward compatible + + expect(result).toContain('export interface BearerAuth'); + expect(result).toContain('export interface BasicAuth'); + expect(result).toContain('export interface ApiKeyAuth'); + expect(result).toContain('export interface OAuth2Auth'); + expect(result).toContain( + 'export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth' + ); + }); + + it('should generate all auth types when empty security schemes array provided', () => { + const result = renderHttpCommonTypes([]); + + expect(result).toContain('export interface BearerAuth'); + expect(result).toContain('export interface BasicAuth'); + expect(result).toContain('export interface ApiKeyAuth'); + expect(result).toContain('export interface OAuth2Auth'); + }); + + it('should generate auth interface with pre-populated apiKey name and location', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'petstore_api_key', + type: 'apiKey', + apiKeyName: 'X-Petstore-Key', + apiKeyIn: 'query' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + // The generated interface should have name and in pre-set + expect(result).toContain('X-Petstore-Key'); + expect(result).toContain('query'); + }); + + it('should generate OAuth2 auth with tokenUrl from implicit flow', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'oauth2', + type: 'oauth2', + oauth2Flows: { + implicit: { + authorizationUrl: 'https://example.com/authorize', + scopes: {} + } + } + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + expect(result).toContain('export interface OAuth2Auth'); + expect(result).toContain('https://example.com/authorize'); + }); + + it('should handle openIdConnect type', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'oidc', + type: 'openIdConnect', + openIdConnectUrl: 'https://example.com/.well-known/openid-configuration' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + // OpenID Connect should be treated similar to OAuth2 + expect(result).toContain('export interface OAuth2Auth'); + expect(result).toContain('https://example.com/.well-known/openid-configuration'); + }); + + it('should deduplicate auth types when same type defined multiple times', () => { + const securitySchemes: ExtractedSecurityScheme[] = [ + { + name: 'api_key_header', + type: 'apiKey', + apiKeyName: 'X-API-Key', + apiKeyIn: 'header' + }, + { + name: 'api_key_query', + type: 'apiKey', + apiKeyName: 'api_key', + apiKeyIn: 'query' + } + ]; + + const result = renderHttpCommonTypes(securitySchemes); + + // Should only have one ApiKeyAuth interface + const apiKeyInterfaceMatches = result.match(/export interface ApiKeyAuth/g); + expect(apiKeyInterfaceMatches).toHaveLength(1); + + // AuthConfig should still only have ApiKeyAuth once + expect(result).toContain('export type AuthConfig = ApiKeyAuth'); + expect(result).not.toMatch(/ApiKeyAuth \| ApiKeyAuth/); + }); + }); +}); diff --git a/test/codegen/inputs/openapi/security.spec.ts b/test/codegen/inputs/openapi/security.spec.ts new file mode 100644 index 00000000..46a5963b --- /dev/null +++ b/test/codegen/inputs/openapi/security.spec.ts @@ -0,0 +1,546 @@ +import {OpenAPIV3, OpenAPIV2} from 'openapi-types'; +import { + extractSecuritySchemes, + ExtractedSecurityScheme +} from '../../../../src/codegen/inputs/openapi/security'; + +describe('OpenAPI Security Extraction', () => { + describe('extractSecuritySchemes', () => { + describe('OpenAPI 3.x', () => { + it('should extract apiKey security scheme from header', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + api_key: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'api_key', + type: 'apiKey', + apiKeyName: 'X-API-Key', + apiKeyIn: 'header' + }); + }); + + it('should extract apiKey security scheme from query', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + api_key: { + type: 'apiKey', + name: 'api_key', + in: 'query' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'api_key', + type: 'apiKey', + apiKeyName: 'api_key', + apiKeyIn: 'query' + }); + }); + + it('should extract apiKey security scheme from cookie', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + session: { + type: 'apiKey', + name: 'session_id', + in: 'cookie' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'session', + type: 'apiKey', + apiKeyName: 'session_id', + apiKeyIn: 'cookie' + }); + }); + + it('should extract http bearer security scheme', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'bearerAuth', + type: 'http', + httpScheme: 'bearer', + bearerFormat: 'JWT' + }); + }); + + it('should extract http basic security scheme', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + basicAuth: { + type: 'http', + scheme: 'basic' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'basicAuth', + type: 'http', + httpScheme: 'basic' + }); + }); + + it('should extract oauth2 implicit flow', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + oauth2: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: { + 'read:pets': 'read your pets', + 'write:pets': 'modify pets in your account' + } + } + } + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'oauth2', + type: 'oauth2', + oauth2Flows: { + implicit: { + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: { + 'read:pets': 'read your pets', + 'write:pets': 'modify pets in your account' + } + } + } + }); + }); + + it('should extract oauth2 authorization code flow', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + oauth2: { + type: 'oauth2', + flows: { + authorizationCode: { + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: { + 'read:users': 'read user data' + } + } + } + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0].oauth2Flows?.authorizationCode).toEqual({ + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: { + 'read:users': 'read user data' + } + }); + }); + + it('should extract oauth2 client credentials flow', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + clientCredentials: { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/oauth/token', + scopes: { + admin: 'admin access' + } + } + } + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0].oauth2Flows?.clientCredentials).toEqual({ + tokenUrl: 'https://example.com/oauth/token', + scopes: { + admin: 'admin access' + } + }); + }); + + it('should extract oauth2 password flow', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + passwordAuth: { + type: 'oauth2', + flows: { + password: { + tokenUrl: 'https://example.com/oauth/token', + scopes: {} + } + } + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0].oauth2Flows?.password).toEqual({ + tokenUrl: 'https://example.com/oauth/token', + scopes: {} + }); + }); + + it('should extract openIdConnect security scheme', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + oidc: { + type: 'openIdConnect', + openIdConnectUrl: + 'https://example.com/.well-known/openid-configuration' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'oidc', + type: 'openIdConnect', + openIdConnectUrl: + 'https://example.com/.well-known/openid-configuration' + }); + }); + + it('should extract multiple security schemes', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: { + securitySchemes: { + api_key: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header' + }, + bearerAuth: { + type: 'http', + scheme: 'bearer' + }, + oauth2: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: {} + } + } + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(3); + expect(schemes.map((s) => s.name)).toContain('api_key'); + expect(schemes.map((s) => s.name)).toContain('bearerAuth'); + expect(schemes.map((s) => s.name)).toContain('oauth2'); + }); + + it('should return empty array when no security schemes defined', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {} + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toEqual([]); + }); + + it('should return empty array when components is empty', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + components: {} + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toEqual([]); + }); + }); + + describe('Swagger 2.0 (OpenAPI 2.0)', () => { + it('should extract apiKey security definition from header', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + securityDefinitions: { + api_key: { + type: 'apiKey', + name: 'api_key', + in: 'header' + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'api_key', + type: 'apiKey', + apiKeyName: 'api_key', + apiKeyIn: 'header' + }); + }); + + it('should extract basic security definition', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + securityDefinitions: { + basicAuth: { + type: 'basic' + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'basicAuth', + type: 'http', + httpScheme: 'basic' + }); + }); + + it('should extract oauth2 implicit flow from Swagger 2.0', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + securityDefinitions: { + petstore_auth: { + type: 'oauth2', + flow: 'implicit', + authorizationUrl: 'https://petstore.swagger.io/oauth/authorize', + scopes: { + 'write:pets': 'modify pets', + 'read:pets': 'read pets' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0]).toEqual({ + name: 'petstore_auth', + type: 'oauth2', + oauth2Flows: { + implicit: { + authorizationUrl: 'https://petstore.swagger.io/oauth/authorize', + scopes: { + 'write:pets': 'modify pets', + 'read:pets': 'read pets' + } + } + } + }); + }); + + it('should extract oauth2 password flow from Swagger 2.0', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + securityDefinitions: { + password_auth: { + type: 'oauth2', + flow: 'password', + tokenUrl: 'https://example.com/oauth/token', + scopes: {} + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0].oauth2Flows?.password).toEqual({ + tokenUrl: 'https://example.com/oauth/token', + scopes: {} + }); + }); + + it('should extract oauth2 application (client_credentials) flow from Swagger 2.0', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + securityDefinitions: { + app_auth: { + type: 'oauth2', + flow: 'application', + tokenUrl: 'https://example.com/oauth/token', + scopes: { + admin: 'admin access' + } + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0].oauth2Flows?.clientCredentials).toEqual({ + tokenUrl: 'https://example.com/oauth/token', + scopes: { + admin: 'admin access' + } + }); + }); + + it('should extract oauth2 accessCode (authorization_code) flow from Swagger 2.0', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {}, + securityDefinitions: { + code_auth: { + type: 'oauth2', + flow: 'accessCode', + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: {} + } + } + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toHaveLength(1); + expect(schemes[0].oauth2Flows?.authorizationCode).toEqual({ + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: {} + }); + }); + + it('should return empty array when no securityDefinitions in Swagger 2.0', () => { + const document: OpenAPIV2.Document = { + swagger: '2.0', + info: {title: 'Test API', version: '1.0.0'}, + paths: {} + }; + + const schemes = extractSecuritySchemes(document); + + expect(schemes).toEqual([]); + }); + }); + }); +}); diff --git a/test/runtime/typescript/src/openapi/channels/http_client.ts b/test/runtime/typescript/src/openapi/channels/http_client.ts index a552de90..06f1f8a9 100644 --- a/test/runtime/typescript/src/openapi/channels/http_client.ts +++ b/test/runtime/typescript/src/openapi/channels/http_client.ts @@ -97,30 +97,13 @@ export interface TokenResponse { // Security Configuration Types - Grouped for better autocomplete // ============================================================================ -/** - * Bearer token authentication configuration - */ -export interface BearerAuth { - type: 'bearer'; - token: string; -} - -/** - * Basic authentication configuration (username/password) - */ -export interface BasicAuth { - type: 'basic'; - username: string; - password: string; -} - /** * API key authentication configuration */ export interface ApiKeyAuth { type: 'apiKey'; key: string; - name?: string; // Name of the API key parameter (default: 'X-API-Key') + name?: string; // Name of the API key parameter (default: 'api_key') in?: 'header' | 'query'; // Where to place the API key (default: 'header') } @@ -134,6 +117,7 @@ export interface ApiKeyAuth { * * For browser-based flows (implicit, authorization_code), obtain the token * separately and pass it as accessToken. + * Authorization URL: 'http://petstore.swagger.io/api/oauth/dialog' */ export interface OAuth2Auth { type: 'oauth2'; @@ -147,7 +131,7 @@ export interface OAuth2Auth { clientId?: string; /** Client secret (optional, depends on OAuth provider) */ clientSecret?: string; - /** Requested scopes */ + /** Requested scopes Available: write:pets, read:pets */ scopes?: string[]; /** Server-side flow type */ flow?: 'password' | 'client_credentials'; @@ -162,7 +146,24 @@ export interface OAuth2Auth { /** * Union type for all authentication methods - provides autocomplete support */ -export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; +export type AuthConfig = ApiKeyAuth | OAuth2Auth; + +/** + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. + */ +const AUTH_FEATURES = { + oauth2: true +} as const; + +/** + * Default values for API key authentication derived from the spec. + * These match the defaults documented in the ApiKeyAuth interface. + */ +const API_KEY_DEFAULTS = { + name: 'api_key', + in: 'header' as 'header' | 'query' | 'cookie' +} as const; // ============================================================================ // Pagination Types @@ -356,14 +357,16 @@ function applyAuth( } case 'apiKey': { - const keyName = auth.name ?? 'X-API-Key'; - const keyIn = auth.in ?? 'header'; + const keyName = auth.name ?? API_KEY_DEFAULTS.name; + const keyIn = auth.in ?? API_KEY_DEFAULTS.in; if (keyIn === 'header') { headers[keyName] = auth.key; - } else { + } else if (keyIn === 'query') { const separator = url.includes('?') ? '&' : '?'; url = `${url}${separator}${keyName}=${encodeURIComponent(auth.key)}`; + } else if (keyIn === 'cookie') { + headers['Cookie'] = `${keyName}=${auth.key}`; } break; } @@ -381,34 +384,6 @@ function applyAuth( return { headers, url }; } -/** - * Validate OAuth2 configuration based on flow type - */ -function validateOAuth2Config(auth: OAuth2Auth): void { - // If using a flow, validate required fields - switch (auth.flow) { - case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); - break; - - case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); - break; - - default: - // No flow specified - must have accessToken for OAuth2 to work - if (!auth.accessToken && !auth.flow) { - // This is fine - token refresh can still work if refreshToken is provided - // Or the request will just be made without auth - } - break; - } -} - /** * Apply pagination parameters to URL and/or headers based on configuration */ @@ -598,126 +573,6 @@ async function executeWithRetry( throw lastError ?? new Error('Request failed after retries'); } -/** - * Handle OAuth2 token flows (client_credentials, password) - */ -async function handleOAuth2TokenFlow( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.flow || !auth.tokenUrl) return null; - - const params = new URLSearchParams(); - - if (auth.flow === 'client_credentials') { - params.append('grant_type', 'client_credentials'); - params.append('client_id', auth.clientId!); - } else if (auth.flow === 'password') { - params.append('grant_type', 'password'); - params.append('username', auth.username || ''); - params.append('password', auth.password || ''); - params.append('client_id', auth.clientId!); - } else { - return null; - } - - if (auth.clientSecret) { - params.append('client_secret', auth.clientSecret); - } - if (auth.scopes && auth.scopes.length > 0) { - params.append('scope', auth.scopes.join(' ')); - } - - const authHeaders: Record = { - 'Content-Type': 'application/x-www-form-urlencoded' - }; - - // Use basic auth for client credentials if both client ID and secret are provided - if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { - const credentials = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64'); - authHeaders['Authorization'] = `Basic ${credentials}`; - params.delete('client_id'); - params.delete('client_secret'); - } - - const tokenResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: authHeaders, - body: params.toString() - }); - - if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); - } - - const tokenData = await tokenResponse.json(); - const tokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(tokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - -/** - * Handle OAuth2 token refresh on 401 response - */ -async function handleTokenRefresh( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; - - const refreshResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: auth.refreshToken, - client_id: auth.clientId, - ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) - }).toString() - }); - - if (!refreshResponse.ok) { - throw new Error('Unauthorized'); - } - - const tokenData = await refreshResponse.json(); - const newTokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || auth.refreshToken, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the refreshed tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(newTokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - /** * Handle HTTP error status codes with standardized messages */ @@ -990,6 +845,153 @@ function applyTypedHeaders( return headers; } +/** + * Validate OAuth2 configuration based on flow type + */ +function validateOAuth2Config(auth: OAuth2Auth): void { + // If using a flow, validate required fields + switch (auth.flow) { + case 'client_credentials': + if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + break; + + case 'password': + if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new Error('OAuth2 Password flow requires username'); + if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + break; + + default: + // No flow specified - must have accessToken for OAuth2 to work + if (!auth.accessToken && !auth.flow) { + // This is fine - token refresh can still work if refreshToken is provided + // Or the request will just be made without auth + } + break; + } +} + +/** + * Handle OAuth2 token flows (client_credentials, password) + */ +async function handleOAuth2TokenFlow( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.flow || !auth.tokenUrl) return null; + + const params = new URLSearchParams(); + + if (auth.flow === 'client_credentials') { + params.append('grant_type', 'client_credentials'); + params.append('client_id', auth.clientId!); + } else if (auth.flow === 'password') { + params.append('grant_type', 'password'); + params.append('username', auth.username || ''); + params.append('password', auth.password || ''); + params.append('client_id', auth.clientId!); + } else { + return null; + } + + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } + + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // Use basic auth for client credentials if both client ID and secret are provided + if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { + const credentials = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64'); + authHeaders['Authorization'] = `Basic ${credentials}`; + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (!tokenResponse.ok) { + throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + } + + const tokenData = await tokenResponse.json(); + const tokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} + +/** + * Handle OAuth2 token refresh on 401 response + */ +async function handleTokenRefresh( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; + + const refreshResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) + }).toString() + }); + + if (!refreshResponse.ok) { + throw new Error('Unauthorized'); + } + + const tokenData = await refreshResponse.json(); + const newTokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || auth.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the refreshed tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} // ============================================================================ // Generated HTTP Client Functions // ============================================================================ @@ -1008,7 +1010,7 @@ async function postAddPet(context: PostAddPetContext): Promise Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.flow || !auth.tokenUrl) return null; - - const params = new URLSearchParams(); - - if (auth.flow === 'client_credentials') { - params.append('grant_type', 'client_credentials'); - params.append('client_id', auth.clientId!); - } else if (auth.flow === 'password') { - params.append('grant_type', 'password'); - params.append('username', auth.username || ''); - params.append('password', auth.password || ''); - params.append('client_id', auth.clientId!); - } else { - return null; - } - - if (auth.clientSecret) { - params.append('client_secret', auth.clientSecret); - } - if (auth.scopes && auth.scopes.length > 0) { - params.append('scope', auth.scopes.join(' ')); - } - - const authHeaders: Record = { - 'Content-Type': 'application/x-www-form-urlencoded' - }; - - // Use basic auth for client credentials if both client ID and secret are provided - if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { - const credentials = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64'); - authHeaders['Authorization'] = `Basic ${credentials}`; - params.delete('client_id'); - params.delete('client_secret'); - } - - const tokenResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: authHeaders, - body: params.toString() - }); - - if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); - } - - const tokenData = await tokenResponse.json(); - const tokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(tokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - -/** - * Handle OAuth2 token refresh on 401 response - */ -async function handleTokenRefresh( - auth: OAuth2Auth, - originalParams: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; - - const refreshResponse = await NodeFetch.default(auth.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: auth.refreshToken, - client_id: auth.clientId, - ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) - }).toString() - }); - - if (!refreshResponse.ok) { - throw new Error('Unauthorized'); - } - - const tokenData = await refreshResponse.json(); - const newTokens: TokenResponse = { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || auth.refreshToken, - expiresIn: tokenData.expires_in - }; - - // Notify the client about the refreshed tokens - if (auth.onTokenRefresh) { - auth.onTokenRefresh(newTokens); - } - - // Retry the original request with the new token - const updatedHeaders = { ...originalParams.headers }; - updatedHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`; - - return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); -} - /** * Handle HTTP error status codes with standardized messages */ @@ -991,6 +862,153 @@ function applyTypedHeaders( return headers; } +/** + * Validate OAuth2 configuration based on flow type + */ +function validateOAuth2Config(auth: OAuth2Auth): void { + // If using a flow, validate required fields + switch (auth.flow) { + case 'client_credentials': + if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + break; + + case 'password': + if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new Error('OAuth2 Password flow requires username'); + if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + break; + + default: + // No flow specified - must have accessToken for OAuth2 to work + if (!auth.accessToken && !auth.flow) { + // This is fine - token refresh can still work if refreshToken is provided + // Or the request will just be made without auth + } + break; + } +} + +/** + * Handle OAuth2 token flows (client_credentials, password) + */ +async function handleOAuth2TokenFlow( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.flow || !auth.tokenUrl) return null; + + const params = new URLSearchParams(); + + if (auth.flow === 'client_credentials') { + params.append('grant_type', 'client_credentials'); + params.append('client_id', auth.clientId!); + } else if (auth.flow === 'password') { + params.append('grant_type', 'password'); + params.append('username', auth.username || ''); + params.append('password', auth.password || ''); + params.append('client_id', auth.clientId!); + } else { + return null; + } + + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } + + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // Use basic auth for client credentials if both client ID and secret are provided + if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { + const credentials = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64'); + authHeaders['Authorization'] = `Basic ${credentials}`; + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (!tokenResponse.ok) { + throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + } + + const tokenData = await tokenResponse.json(); + const tokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} + +/** + * Handle OAuth2 token refresh on 401 response + */ +async function handleTokenRefresh( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; + + const refreshResponse = await NodeFetch.default(auth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) + }).toString() + }); + + if (!refreshResponse.ok) { + throw new Error('Unauthorized'); + } + + const tokenData = await refreshResponse.json(); + const newTokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || auth.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the refreshed tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} // ============================================================================ // Generated HTTP Client Functions // ============================================================================ @@ -1009,7 +1027,7 @@ async function postPingPostRequest(context: PostPingPostRequestContext): Promise }; // Validate OAuth2 config if present - if (config.auth?.type === 'oauth2') { + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { validateOAuth2Config(config.auth); } @@ -1061,7 +1079,7 @@ async function postPingPostRequest(context: PostPingPostRequestContext): Promise } // Handle OAuth2 token flows that require getting a token first - if (config.auth?.type === 'oauth2' && !config.auth.accessToken) { + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); if (tokenFlowResponse) { response = tokenFlowResponse; @@ -1069,7 +1087,7 @@ async function postPingPostRequest(context: PostPingPostRequestContext): Promise } // Handle 401 with token refresh - if (response.status === 401 && config.auth?.type === 'oauth2') { + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { try { const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); if (refreshResponse) { @@ -1128,7 +1146,7 @@ async function getPingGetRequest(context: GetPingGetRequestContext = {}): Promis }; // Validate OAuth2 config if present - if (config.auth?.type === 'oauth2') { + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { validateOAuth2Config(config.auth); } @@ -1180,7 +1198,7 @@ async function getPingGetRequest(context: GetPingGetRequestContext = {}): Promis } // Handle OAuth2 token flows that require getting a token first - if (config.auth?.type === 'oauth2' && !config.auth.accessToken) { + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); if (tokenFlowResponse) { response = tokenFlowResponse; @@ -1188,7 +1206,7 @@ async function getPingGetRequest(context: GetPingGetRequestContext = {}): Promis } // Handle 401 with token refresh - if (response.status === 401 && config.auth?.type === 'oauth2') { + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { try { const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); if (refreshResponse) { @@ -1248,7 +1266,7 @@ async function putPingPutRequest(context: PutPingPutRequestContext): Promise { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: API_KEY, @@ -61,10 +61,10 @@ describe('HTTP Client - API Key and Basic Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: API_KEY, @@ -106,10 +106,10 @@ describe('HTTP Client - API Key and Basic Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'basic', username: USERNAME, @@ -146,10 +146,10 @@ describe('HTTP Client - API Key and Basic Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'bearer', token: BEARER_TOKEN @@ -172,11 +172,11 @@ describe('HTTP Client - API Key and Basic Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: 'wrong-api-key', diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/authentication.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/authentication.spec.ts index 65a55529..646ed2c3 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/authentication.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/authentication.spec.ts @@ -23,11 +23,11 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: AuthConfig = { type: 'bearer', token: 'test-token-123' }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -50,11 +50,11 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: AuthConfig = { type: 'basic', username: 'user', password: 'pass' }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -78,11 +78,11 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: AuthConfig = { type: 'apiKey', key: 'my-api-key-123' }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -103,7 +103,7 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: AuthConfig = { type: 'apiKey', key: 'my-api-key-123', @@ -112,7 +112,7 @@ describe('HTTP Client - Authentication', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -133,7 +133,7 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: AuthConfig = { type: 'apiKey', key: 'custom-key-value', @@ -142,7 +142,7 @@ describe('HTTP Client - Authentication', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -165,9 +165,9 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: 'secret-key', @@ -199,14 +199,14 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: AuthConfig = { type: 'oauth2', accessToken: 'oauth-access-token-xyz' }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -233,9 +233,9 @@ describe('HTTP Client - Authentication', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'bearer', token: 'my-token' }, additionalHeaders: { 'X-Custom-Header': 'custom-value', diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/basics.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/basics.spec.ts index f6b876d6..900ab24a 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/basics.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/basics.spec.ts @@ -24,10 +24,10 @@ describe('HTTP Client - Basics', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); expect(response.data).toBeDefined(); @@ -53,9 +53,9 @@ describe('HTTP Client - Basics', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'offset', offset: 0, limit: 20 } }); @@ -84,9 +84,9 @@ describe('HTTP Client - Basics', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, queryParams: { filter: 'active', sort: 'name' @@ -107,9 +107,9 @@ describe('HTTP Client - Basics', () => { res.status(401).json({ error: 'Unauthorized' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(getPingGetRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow('Unauthorized'); }); }); @@ -121,9 +121,9 @@ describe('HTTP Client - Basics', () => { res.status(403).json({ error: 'Forbidden' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(getPingGetRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow('Forbidden'); }); }); @@ -135,9 +135,9 @@ describe('HTTP Client - Basics', () => { res.status(404).json({ error: 'Not Found' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(getPingGetRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow('Not Found'); }); }); @@ -149,9 +149,9 @@ describe('HTTP Client - Basics', () => { res.status(500).json({ error: 'Internal Server Error' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(getPingGetRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow('Internal Server Error'); }); }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/hooks.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/hooks.spec.ts index 1c020e98..60d094ae 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/hooks.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/hooks.spec.ts @@ -24,7 +24,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: (params) => ({ ...params, @@ -36,7 +36,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -57,7 +57,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: async (params) => { await new Promise(resolve => setTimeout(resolve, 10)); @@ -72,7 +72,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -94,7 +94,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: (params) => ({ ...params, @@ -106,7 +106,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -127,7 +127,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: (params) => ({ ...params, @@ -136,7 +136,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -159,7 +159,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { afterResponse: (response, params) => { afterResponseCalled = true; @@ -169,7 +169,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -190,7 +190,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { let startTime: number; const hooks: HttpHooks = { @@ -209,7 +209,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -232,7 +232,7 @@ describe('HTTP Client - Hooks', () => { res.status(404).json({ error: 'Not Found' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { onError: (error, params) => { onErrorCalled = true; @@ -243,7 +243,7 @@ describe('HTTP Client - Hooks', () => { try { await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); } catch (error) { @@ -262,7 +262,7 @@ describe('HTTP Client - Hooks', () => { res.status(503).json({ error: 'Service Unavailable', retryAfter: 60 }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { onError: (error, params) => { const enhancedError = new Error(`Request to ${params.url} failed: ${error.message}`); @@ -271,7 +271,7 @@ describe('HTTP Client - Hooks', () => { }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks })).rejects.toThrow(/Request to.*failed/); }); @@ -286,7 +286,7 @@ describe('HTTP Client - Hooks', () => { res.status(500).json({ error: 'Server Error' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { onError: async (error, params) => { await new Promise(resolve => setTimeout(resolve, 10)); @@ -297,7 +297,7 @@ describe('HTTP Client - Hooks', () => { try { await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); } catch (error) { @@ -322,7 +322,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { makeRequest: async (params) => { customMakeRequestCalled = true; @@ -336,7 +336,7 @@ describe('HTTP Client - Hooks', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -359,7 +359,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: (params) => { hookCalls.push('beforeRequest'); @@ -381,7 +381,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks }); @@ -407,7 +407,7 @@ describe('HTTP Client - Hooks', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: (params) => { hookCalls.push('beforeRequest'); @@ -426,7 +426,7 @@ describe('HTTP Client - Hooks', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks, retry }); @@ -450,7 +450,7 @@ describe('HTTP Client - Hooks', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const hooks: HttpHooks = { beforeRequest: (params) => { const url = new URL(params.url); @@ -463,7 +463,7 @@ describe('HTTP Client - Hooks', () => { }; const page1 = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, hooks, pagination: { type: 'offset', offset: 0, limit: 20 } }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/methods.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/methods.spec.ts index 8edb5e35..dc5a2f87 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/methods.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/methods.spec.ts @@ -29,9 +29,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await getPingGetRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); expect(receivedMethod).toBe('GET'); @@ -58,9 +58,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage }); @@ -89,9 +89,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await putPingPutRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage }); @@ -116,9 +116,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await putPingPutRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage, auth: { type: 'bearer', token: 'put-token' } }); @@ -141,9 +141,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await putPingPutRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage, queryParams: { version: '2' } }); @@ -167,9 +167,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await deletePingDeleteRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); expect(receivedMethod).toBe('DELETE'); @@ -191,9 +191,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await deletePingDeleteRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'bearer', token: 'delete-token' } }); @@ -219,9 +219,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await patchPingPatchRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage }); @@ -247,9 +247,9 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await patchPingPatchRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage, pagination: { type: 'offset', offset: 10, limit: 5 } }); @@ -273,12 +273,12 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // HEAD requests return no body, so the generated code may fail // This test verifies the method is sent correctly even if parsing fails try { await headPingHeadRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); } catch (error) { // Expected - HEAD responses have no body to parse @@ -301,10 +301,10 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { await headPingHeadRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: 'head-key' } }); } catch (error) { @@ -330,11 +330,11 @@ describe('HTTP Client - HTTP Methods', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // OPTIONS requests typically return no body try { await optionsPingOptionsRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); } catch (error) { // Expected - OPTIONS responses typically have no body @@ -355,9 +355,9 @@ describe('HTTP Client - HTTP Methods', () => { res.status(404).json({ error: 'Not Found' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(putPingPutRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage })).rejects.toThrow('Not Found'); }); @@ -370,9 +370,9 @@ describe('HTTP Client - HTTP Methods', () => { res.status(500).json({ error: 'Internal Server Error' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(deletePingDeleteRequest({ - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow('Internal Server Error'); }); }); @@ -386,9 +386,9 @@ describe('HTTP Client - HTTP Methods', () => { res.status(403).json({ error: 'Forbidden' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await expect(patchPingPatchRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage })).rejects.toThrow('Forbidden'); }); @@ -414,9 +414,9 @@ describe('HTTP Client - HTTP Methods', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await putPingPutRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, payload: requestMessage, retry: { maxRetries: 3, initialDelayMs: 50, retryableStatusCodes: [503] } }); @@ -443,9 +443,9 @@ describe('HTTP Client - HTTP Methods', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await deletePingDeleteRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry: { maxRetries: 3, initialDelayMs: 50, retryableStatusCodes: [502] } }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2.spec.ts index 141c5a82..16b448e4 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2.spec.ts @@ -36,13 +36,13 @@ describe('HTTP Client - OAuth2', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'client_credentials', clientId: 'test-client-id', clientSecret: 'test-client-secret', - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, scopes: ['read', 'write'], onTokenRefresh: (tokens) => { refreshedTokens.push(tokens); @@ -50,7 +50,7 @@ describe('HTTP Client - OAuth2', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -68,7 +68,7 @@ describe('HTTP Client - OAuth2', () => { res.json({ error: 'should not reach here' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'client_credentials', @@ -76,7 +76,7 @@ describe('HTTP Client - OAuth2', () => { }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth })).rejects.toThrow('OAuth2 Client Credentials flow requires tokenUrl'); }); @@ -109,18 +109,18 @@ describe('HTTP Client - OAuth2', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'password', clientId: 'test-client-id', - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, username: 'testuser', password: 'testpass' }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -138,16 +138,16 @@ describe('HTTP Client - OAuth2', () => { res.json({ error: 'should not reach here' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'password', clientId: 'test-client-id', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth })).rejects.toThrow('OAuth2 Password flow requires username'); }); @@ -184,17 +184,17 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', clientId: 'test-client-id', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -243,13 +243,13 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'client_credentials', clientId: 'test-client-id', clientSecret: 'test-client-secret', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` }; const retry: RetryConfig = { @@ -262,7 +262,7 @@ describe('HTTP Client - OAuth2', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth, retry }); @@ -307,12 +307,12 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'password', clientId: 'test-client-id', - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, username: 'user', password: 'pass' }; @@ -324,7 +324,7 @@ describe('HTTP Client - OAuth2', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth, retry }); @@ -367,13 +367,13 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', clientId: 'test-client-id', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` }; const retry: RetryConfig = { @@ -383,7 +383,7 @@ describe('HTTP Client - OAuth2', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth, retry }); @@ -422,13 +422,13 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'client_credentials', clientId: 'test-client-id', clientSecret: 'test-client-secret', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` }; const retry: RetryConfig = { @@ -438,7 +438,7 @@ describe('HTTP Client - OAuth2', () => { }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth, retry })).rejects.toThrow(); @@ -474,13 +474,13 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'client_credentials', clientId: 'test-client-id', clientSecret: 'test-client-secret', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` }; const retry: RetryConfig = { @@ -494,7 +494,7 @@ describe('HTTP Client - OAuth2', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth, retry }); @@ -527,18 +527,18 @@ describe('HTTP Client - OAuth2', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // Use 'implicit' flow which is not supported for token fetching const auth: OAuth2Auth = { type: 'oauth2', flow: 'implicit' as any, clientId: 'test-client', - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, accessToken: 'pre-existing-token' }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -562,7 +562,7 @@ describe('HTTP Client - OAuth2', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // When no flow is specified but accessToken is provided, // it should be used directly without fetching const auth: OAuth2Auth = { @@ -571,7 +571,7 @@ describe('HTTP Client - OAuth2', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -597,17 +597,17 @@ describe('HTTP Client - OAuth2', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', flow: 'client_credentials', clientId: 'test-client', - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, accessToken: 'already-have-token' // This should prevent token fetch }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); @@ -641,20 +641,20 @@ describe('HTTP Client - OAuth2', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const auth: OAuth2Auth = { type: 'oauth2', accessToken: 'expired-token', refreshToken: 'valid-refresh', clientId: 'test-client', - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, onTokenRefresh: (tokens) => { refreshedTokens.push(tokens); } }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts index 586a0091..feae72fb 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts @@ -69,19 +69,19 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // Mock onTokenRefresh callback const onTokenRefresh = jest.fn(); const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', flow: 'client_credentials', clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, onTokenRefresh } }); @@ -112,16 +112,16 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', flow: 'client_credentials', clientId: CLIENT_ID, - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` } }); throw new Error('Expected request to fail with 401 status'); @@ -213,16 +213,16 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', flow: 'client_credentials', clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` } }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts index b52a9ccf..4a836c8f 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts @@ -58,12 +58,12 @@ describe('HTTP Client - OAuth2 Pre-obtained Access Token', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // This is how you'd use a token obtained from a browser-based flow // (implicit, authorization_code via PKCE, etc.) const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', accessToken: ACCESS_TOKEN @@ -120,18 +120,18 @@ describe('HTTP Client - OAuth2 Pre-obtained Access Token', () => { res.status(401).json({ error: 'Invalid Token' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const onTokenRefresh = jest.fn(); // Use pre-obtained token with refresh capability const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', accessToken: EXPIRED_ACCESS_TOKEN, refreshToken: REFRESH_TOKEN, - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, clientId: CLIENT_ID, onTokenRefresh } @@ -160,11 +160,11 @@ describe('HTTP Client - OAuth2 Pre-obtained Access Token', () => { res.status(401).json({ error: 'Unauthorized' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // Simplest OAuth2 usage - just pass the access token const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', accessToken: ACCESS_TOKEN @@ -189,12 +189,12 @@ describe('HTTP Client - OAuth2 Pre-obtained Access Token', () => { res.json({ success: true }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { // OAuth2 config without access token and no server-side flow await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2' // No accessToken, no flow - should make request without auth header diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts index 1e4b4c50..623f1672 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_password_flow.spec.ts @@ -69,13 +69,13 @@ describe('HTTP Client - OAuth2 Password Flow', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // Mock onTokenRefresh callback const onTokenRefresh = jest.fn(); const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', flow: 'password', @@ -83,7 +83,7 @@ describe('HTTP Client - OAuth2 Password Flow', () => { clientSecret: CLIENT_SECRET, username: USERNAME, password: PASSWORD, - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, onTokenRefresh } }); @@ -117,18 +117,18 @@ describe('HTTP Client - OAuth2 Password Flow', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', flow: 'password', clientId: CLIENT_ID, username: INVALID_USERNAME, password: INVALID_PASSWORD, - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` } }); throw new Error('Expected request to fail with 401 status'); @@ -211,10 +211,10 @@ describe('HTTP Client - OAuth2 Password Flow', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', flow: 'password', @@ -222,7 +222,7 @@ describe('HTTP Client - OAuth2 Password Flow', () => { username: USERNAME, password: PASSWORD, scopes: SCOPES, - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` } }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts index 7d6c78f4..bcd2a6c8 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts @@ -84,20 +84,20 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => { res.status(401).json(TestResponses.unauthorized('Invalid Token').body); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // Mock onTokenRefresh callback const onTokenRefresh = jest.fn(); const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, accessToken: EXPIRED_ACCESS_TOKEN, refreshToken: REFRESH_TOKEN, - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, onTokenRefresh } }); @@ -138,17 +138,17 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => { res.status(401).json(TestResponses.unauthorized('Token Expired').body); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', clientId: CLIENT_ID, accessToken: EXPIRED_ACCESS_TOKEN, refreshToken: INVALID_REFRESH_TOKEN, - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` } }); throw new Error('Expected request to fail with 401 status'); @@ -172,16 +172,16 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => { res.status(401).json(TestResponses.unauthorized('Token Expired').body); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { try { await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', accessToken: EXPIRED_ACCESS_TOKEN, refreshToken: 'refresh-token', - tokenUrl: `http://localhost:${port}/oauth/token` + tokenUrl: `http://localhost:${actualPort}/oauth/token` } as any // Using any to bypass type checking }); throw new Error('Expected request to fail'); @@ -236,19 +236,19 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { // Mock onTokenRefresh callback const onTokenRefresh = jest.fn(); const response = await postPingPostRequest({ payload: requestMessage, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', clientId: CLIENT_ID, accessToken: EXPIRED_ACCESS_TOKEN, refreshToken: REFRESH_TOKEN, - tokenUrl: `http://localhost:${port}/oauth/token`, + tokenUrl: `http://localhost:${actualPort}/oauth/token`, onTokenRefresh } }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/openapi.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/openapi.spec.ts index ea5227d5..fb3bb704 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/openapi.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/openapi.spec.ts @@ -26,10 +26,10 @@ describe('HTTP Client - OpenAPI Generated', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await postAddPet({ payload: requestPet, - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); expect(response.data).toBeDefined(); @@ -55,10 +55,10 @@ describe('HTTP Client - OpenAPI Generated', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await putUpdatePet({ payload: requestPet, - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); expect(receivedMethod).toBe('PUT'); @@ -82,7 +82,7 @@ describe('HTTP Client - OpenAPI Generated', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const params = new FindPetsByStatusAndCategoryParameters({ status: 'available', categoryId: 123 @@ -90,7 +90,7 @@ describe('HTTP Client - OpenAPI Generated', () => { const response = await getFindPetsByStatusAndCategory({ parameters: params, - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` }); expect(receivedPath).toContain('available'); @@ -108,11 +108,11 @@ describe('HTTP Client - OpenAPI Generated', () => { res.status(400).json({ error: 'Bad Request' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pet = new APet({ name: 'Test', photoUrls: [] }); await expect(postAddPet({ payload: pet, - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow(); }); }); @@ -124,7 +124,7 @@ describe('HTTP Client - OpenAPI Generated', () => { res.status(404).json({ error: 'Not Found' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const params = new FindPetsByStatusAndCategoryParameters({ status: 'invalid', categoryId: 999 @@ -132,7 +132,7 @@ describe('HTTP Client - OpenAPI Generated', () => { await expect(getFindPetsByStatusAndCategory({ parameters: params, - server: `http://localhost:${port}` + server: `http://localhost:${actualPort}` })).rejects.toThrow('Not Found'); }); }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/pagination.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/pagination.spec.ts index ac8ed95a..ee74bc9f 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/pagination.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/pagination.spec.ts @@ -25,7 +25,7 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pagination: PaginationConfig = { type: 'offset', offset: 20, @@ -33,7 +33,7 @@ describe('HTTP Client - Pagination', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination }); @@ -57,7 +57,7 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pagination: PaginationConfig = { type: 'offset', in: 'header', @@ -66,7 +66,7 @@ describe('HTTP Client - Pagination', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination }); @@ -90,7 +90,7 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pagination: PaginationConfig = { type: 'offset', offset: 100, @@ -100,7 +100,7 @@ describe('HTTP Client - Pagination', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination }); @@ -126,7 +126,7 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pagination: PaginationConfig = { type: 'cursor', cursor: 'abc123xyz', @@ -134,7 +134,7 @@ describe('HTTP Client - Pagination', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination }); @@ -165,9 +165,9 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const page1 = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'cursor', limit: 10 } }); @@ -202,7 +202,7 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pagination: PaginationConfig = { type: 'page', page: 3, @@ -210,7 +210,7 @@ describe('HTTP Client - Pagination', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination }); @@ -234,7 +234,7 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const pagination: PaginationConfig = { type: 'range', start: 0, @@ -243,7 +243,7 @@ describe('HTTP Client - Pagination', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination }); @@ -265,9 +265,9 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const page1 = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'range', start: 0, end: 24, unit: 'items' } }); @@ -293,9 +293,9 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'offset', offset: 0, limit: 20 } }); @@ -324,9 +324,9 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const page1 = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'offset', offset: 0, limit: 20 } }); @@ -357,9 +357,9 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const page = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'offset', offset: 60, limit: 20 } }); @@ -383,9 +383,9 @@ describe('HTTP Client - Pagination', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, pagination: { type: 'page', page: 1, pageSize: 20 } }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/parameters-headers.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/parameters-headers.spec.ts index 23da52e2..57ccf4a7 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/parameters-headers.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/parameters-headers.spec.ts @@ -27,14 +27,14 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const parameters = new UserItemsParameters({ userId: 'user-123', itemId: '456' }); const response = await getGetUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters }); @@ -61,14 +61,14 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await getGetUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'alice', itemId: '100' }) }); await getGetUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'bob', itemId: '200' }) }); @@ -96,9 +96,9 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await getGetUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'secure-user', itemId: '999' }), auth: { type: 'bearer', token: 'secret-token' } }); @@ -128,9 +128,9 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await getGetUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'user1', itemId: '42' }), queryParams: { include: 'metadata', @@ -164,7 +164,7 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const headers = new ItemRequestHeaders({ xCorrelationId: 'corr-123-abc', xRequestId: 'req-456-def' @@ -177,7 +177,7 @@ describe('HTTP Client - Parameters and Headers', () => { }); const response = await putUpdateUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'user-1', itemId: '100' }), payload, requestHeaders: headers @@ -206,7 +206,7 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const headers = new ItemRequestHeaders({ xCorrelationId: 'required-only' }); @@ -216,7 +216,7 @@ describe('HTTP Client - Parameters and Headers', () => { }); const response = await putUpdateUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'u1', itemId: '1' }), payload, requestHeaders: headers @@ -245,9 +245,9 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await putUpdateUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'u', itemId: '1' }), payload: new ItemRequest({ name: 'Item' }), requestHeaders: new ItemRequestHeaders({ xCorrelationId: 'corr-id' }), @@ -279,9 +279,9 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { await putUpdateUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'u', itemId: '1' }), payload: new ItemRequest({ name: 'Secure Item' }), requestHeaders: new ItemRequestHeaders({ xCorrelationId: 'secure-corr' }), @@ -326,9 +326,9 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const response = await putUpdateUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters: new UserItemsParameters({ userId: 'full-user', itemId: '999' }), payload: new ItemRequest({ name: 'Complete Item', @@ -364,7 +364,7 @@ describe('HTTP Client - Parameters and Headers', () => { }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const parameters = new UserItemsParameters({ userId: 'user-1', itemId: 'non-existent' @@ -372,7 +372,7 @@ describe('HTTP Client - Parameters and Headers', () => { try { const response = await getGetUserItem({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, parameters }); expect(response.status).toBe(404); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/retry.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/retry.spec.ts index 23383fa8..586a07c3 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/retry.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/retry.spec.ts @@ -27,7 +27,7 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 100, @@ -35,7 +35,7 @@ describe('HTTP Client - Retry Logic', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -62,7 +62,7 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 5, initialDelayMs: 30, @@ -73,7 +73,7 @@ describe('HTTP Client - Retry Logic', () => { }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -103,7 +103,7 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50, @@ -114,7 +114,7 @@ describe('HTTP Client - Retry Logic', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -135,7 +135,7 @@ describe('HTTP Client - Retry Logic', () => { res.status(500).json({ error: 'Server Error' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50, @@ -143,7 +143,7 @@ describe('HTTP Client - Retry Logic', () => { }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry })).rejects.toThrow('Internal Server Error'); @@ -171,7 +171,7 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 5, initialDelayMs: 100, @@ -183,7 +183,7 @@ describe('HTTP Client - Retry Logic', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -212,7 +212,7 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 5, initialDelayMs: 100, @@ -225,7 +225,7 @@ describe('HTTP Client - Retry Logic', () => { }; await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -246,7 +246,7 @@ describe('HTTP Client - Retry Logic', () => { res.status(400).json({ error: 'Bad Request' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50, @@ -254,7 +254,7 @@ describe('HTTP Client - Retry Logic', () => { }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry })).rejects.toThrow(); @@ -279,14 +279,14 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50 }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -305,7 +305,7 @@ describe('HTTP Client - Retry Logic', () => { res.status(500).json({ error: 'Server Error' }); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50, @@ -313,7 +313,7 @@ describe('HTTP Client - Retry Logic', () => { }; await expect(getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry })).rejects.toThrow('Internal Server Error'); @@ -338,14 +338,14 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50 }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -371,14 +371,14 @@ describe('HTTP Client - Retry Logic', () => { } }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50 }; const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); @@ -432,7 +432,7 @@ describe('HTTP Client - Retry Logic', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const retry: RetryConfig = { maxRetries: 3, initialDelayMs: 50 @@ -440,7 +440,7 @@ describe('HTTP Client - Retry Logic', () => { // Request should succeed on first try const response = await getPingGetRequest({ - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, retry }); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/security_schemes.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/security_schemes.spec.ts new file mode 100644 index 00000000..05399a22 --- /dev/null +++ b/test/runtime/typescript/test/channels/request_reply/http_client/security_schemes.spec.ts @@ -0,0 +1,235 @@ +/* eslint-disable no-console */ +/** + * Runtime tests for dynamically generated security types from OpenAPI securitySchemes. + * + * These tests verify that: + * 1. Generated AuthConfig only contains auth types defined in the OpenAPI spec + * 2. The narrowed types work correctly at runtime + * 3. OAuth2 auth works with access tokens + * 4. The generated code compiles and works correctly at runtime + */ +import {createTestServer, runWithServer} from './test-utils'; +import {APet} from '../../../../src/openapi/payloads/APet'; +import { + postAddPet +} from '../../../../src/openapi/channels/http_client'; + +// Type-level test: Verify AuthConfig is narrowed to only defined types +// If this compiles, the types are correctly generated +type AssertAuthConfigHasOAuth2 = Extract< + import('../../../../src/openapi/channels/http_client').AuthConfig, + {type: 'oauth2'} +> extends never + ? 'missing' + : 'present'; + +type AssertAuthConfigHasApiKey = Extract< + import('../../../../src/openapi/channels/http_client').AuthConfig, + {type: 'apiKey'} +> extends never + ? 'missing' + : 'present'; + +// These should NOT exist if security schemes are properly extracted +type AssertAuthConfigMissingBasic = Extract< + import('../../../../src/openapi/channels/http_client').AuthConfig, + {type: 'basic'} +> extends never + ? 'correctly-missing' + : 'unexpectedly-present'; + +type AssertAuthConfigMissingBearer = Extract< + import('../../../../src/openapi/channels/http_client').AuthConfig, + {type: 'bearer'} +> extends never + ? 'correctly-missing' + : 'unexpectedly-present'; + +// Type assertions - these will fail at compile time if the types are wrong +const _assertOAuth2Present: AssertAuthConfigHasOAuth2 = 'present'; +const _assertApiKeyPresent: AssertAuthConfigHasApiKey = 'present'; +const _assertBasicMissing: AssertAuthConfigMissingBasic = 'correctly-missing'; +const _assertBearerMissing: AssertAuthConfigMissingBearer = 'correctly-missing'; + +jest.setTimeout(15000); + +describe('HTTP Client - Security Schemes from OpenAPI', () => { + describe('apiKey authentication (api_key from spec)', () => { + it('should allow apiKey auth type since it is in the spec', async () => { + const {app, router, port} = createTestServer(); + + const responsePet = new APet({ + id: 1, + name: 'Fluffy', + photoUrls: ['http://example.com/fluffy.jpg'] + }); + let receivedApiKey: string | undefined; + + // The spec defines: name: "api_key", in: "header" + // The generated code uses 'api_key' as the default header name (from spec) + router.post('/pet', (req, res) => { + receivedApiKey = req.headers['api_key'] as string; + res.setHeader('Content-Type', 'application/json'); + res.write(responsePet.marshal()); + res.end(); + }); + + return runWithServer(app, port, async (_server, actualPort) => { + const requestPet = new APet({ + name: 'Fluffy', + photoUrls: ['http://example.com/fluffy.jpg'] + }); + + // Use apiKey auth - users need to provide name/in but the defaults + // from the spec are documented in the generated interface + await postAddPet({ + payload: requestPet, + server: `http://localhost:${actualPort}`, + auth: { + type: 'apiKey', + key: 'my-secret-api-key' + // Uses default header name 'api_key' from spec when not specified + } + }); + + expect(receivedApiKey).toBe('my-secret-api-key'); + }); + }); + + it('should allow specifying custom apiKey name from spec', async () => { + const {app, router, port} = createTestServer(); + + const responsePet = new APet({ + id: 1, + name: 'Fluffy', + photoUrls: [] + }); + let receivedApiKey: string | undefined; + + // The spec defines: name: "api_key", in: "header" + router.post('/pet', (req, res) => { + receivedApiKey = req.headers['api_key'] as string; + res.setHeader('Content-Type', 'application/json'); + res.write(responsePet.marshal()); + res.end(); + }); + + return runWithServer(app, port, async (_server, actualPort) => { + const requestPet = new APet({ + name: 'Fluffy', + photoUrls: [] + }); + + // Use the header name from the spec + await postAddPet({ + payload: requestPet, + server: `http://localhost:${actualPort}`, + auth: { + type: 'apiKey', + key: 'my-secret-api-key', + name: 'api_key', // From spec: components.securitySchemes.api_key.name + in: 'header' // From spec: components.securitySchemes.api_key.in + } + }); + + expect(receivedApiKey).toBe('my-secret-api-key'); + }); + }); + }); + + describe('oauth2 authentication (petstore_auth from spec)', () => { + it('should allow oauth2 auth type since it is in the spec', async () => { + const {app, router, port} = createTestServer(); + + const responsePet = new APet({ + id: 1, + name: 'Fluffy', + photoUrls: ['http://example.com/fluffy.jpg'] + }); + + router.post('/pet', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.write(responsePet.marshal()); + res.end(); + }); + + return runWithServer(app, port, async (_server, actualPort) => { + const requestPet = new APet({ + name: 'Fluffy', + photoUrls: ['http://example.com/fluffy.jpg'] + }); + + // With a pre-obtained token, oauth2 works + const response = await postAddPet({ + payload: requestPet, + server: `http://localhost:${actualPort}`, + auth: { + type: 'oauth2', + accessToken: 'pre-obtained-token' + } + }); + + expect(response.status).toBe(200); + }); + }); + + it('should accept scopes as the spec defines them', async () => { + const {app, router, port} = createTestServer(); + + const responsePet = new APet({ + id: 1, + name: 'Fluffy', + photoUrls: [] + }); + + router.post('/pet', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.write(responsePet.marshal()); + res.end(); + }); + + return runWithServer(app, port, async (_server, actualPort) => { + const requestPet = new APet({ + name: 'Test', + photoUrls: [] + }); + + // The spec defines scopes: write:pets, read:pets + // The generated interface documents these in the JSDoc + await postAddPet({ + payload: requestPet, + server: `http://localhost:${actualPort}`, + auth: { + type: 'oauth2', + accessToken: 'token', + scopes: ['write:pets', 'read:pets'] + } + }); + }); + }); + }); + + describe('auth type narrowing', () => { + it('should reject invalid auth types at compile time', async () => { + // This test verifies at compile time that invalid auth types are rejected + // If the security schemes are properly extracted, 'basic' and 'bearer' + // should not be valid auth types for this OpenAPI spec + // + // The following commented code would cause a TypeScript error: + // + // await postAddPet({ + // payload: requestPet, + // server: `http://localhost:${actualPort}`, + // auth: { type: 'basic', username: 'user', password: 'pass' } // TypeScript Error! + // }); + // + // await postAddPet({ + // payload: requestPet, + // server: `http://localhost:${actualPort}`, + // auth: { type: 'bearer', token: 'token' } // TypeScript Error! + // }); + + expect(true).toBe(true); // Placeholder - the real test is at compile time + }); + }); +}); diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts b/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts index 0064a3cf..0eafab24 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/test-utils.ts @@ -1,6 +1,6 @@ import express, { Router, Express } from 'express'; import bodyParser from 'body-parser'; -import { Server } from 'http'; +import { Server, AddressInfo } from 'http'; /** * Helper function to create an Express server for HTTP client tests @@ -16,26 +16,34 @@ export function createTestServer(): { app.use(express.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true })); app.use(router); - - // Generate a random port between 5779 and 9875 - const port = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779; - - return { app, router, port }; + + // Return a placeholder port - actual port will be assigned dynamically + return { app, router, port: 0 }; } /** * Start an Express server and run the test function - * This handles proper server cleanup after the test + * This handles proper server cleanup after the test. + * Uses port 0 to let the OS assign an available port, avoiding EADDRINUSE errors. */ export function runWithServer( server: Express, - port: number, - testFn: (server: Server) => Promise + _port: number, + testFn: (server: Server, port: number) => Promise ): Promise { return new Promise((resolve, reject) => { - const httpServer = server.listen(port, async () => { + // Use port 0 to let the OS assign an available port + const httpServer = server.listen(0); + + httpServer.on('error', (error) => { + reject(error); + }); + + httpServer.on('listening', async () => { + const address = httpServer.address() as AddressInfo; + const assignedPort = address.port; try { - await testFn(httpServer); + await testFn(httpServer, assignedPort); resolve(); } catch (error) { reject(error);