From 244b49f65e055ebde67fd9c977a0f49a30979227 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sun, 8 Mar 2026 22:08:30 +0100 Subject: [PATCH 01/19] feat: Auto-configure security from OpenAPI securitySchemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #337 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../generators/typescript/channels/openapi.ts | 7 +- .../channels/protocols/http/fetch.ts | 450 ++++++++++-- .../generators/typescript/channels/types.ts | 10 + src/codegen/inputs/openapi/security.ts | 322 +++++++++ .../__snapshots__/channels.spec.ts.snap | 24 +- .../protocols/http/fetch-security.spec.ts | 230 +++++++ test/codegen/inputs/openapi/security.spec.ts | 645 ++++++++++++++++++ .../src/openapi/channels/http_client.ts | 24 +- .../http_client/security_schemes.spec.ts | 235 +++++++ 9 files changed, 1834 insertions(+), 113 deletions(-) create mode 100644 src/codegen/inputs/openapi/security.ts create mode 100644 test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts create mode 100644 test/codegen/inputs/openapi/security.spec.ts create mode 100644 test/runtime/typescript/test/channels/request_reply/http_client/security_schemes.spec.ts 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..94c56e39 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -1,12 +1,387 @@ 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}; + +/** + * 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; +}`; +} + +/** + * Generates the ApiKeyAuth interface with optional pre-populated defaults from spec. + */ +function renderApiKeyAuthInterface( + apiKeySchemes: ExtractedSecurityScheme[] +): string { + // If there's exactly one apiKey scheme, we can provide defaults + let defaultName = 'X-API-Key'; + let defaultIn: string = 'header'; + + if (apiKeySchemes.length === 1) { + defaultName = apiKeySchemes[0].apiKeyName || defaultName; + defaultIn = apiKeySchemes[0].apiKeyIn || defaultIn; + } + + // For cookie support + const inType = apiKeySchemes.some((s) => s.apiKeyIn === 'cookie') + ? "'header' | 'query' | 'cookie'" + : "'header' | 'query'"; + + return `/** + * API key authentication configuration + */ +export interface ApiKeyAuth { + type: 'apiKey'; + key: string; + name?: string; // Name of the API key parameter (default: '${defaultName}') + in?: ${inType}; // Where to place the API key (default: '${defaultIn}') +}`; +} + +/** + * 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: '${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: '${tokenUrl}'` + : existing.tokenUrlComment, + authorizationUrlComment: authUrl + ? ` Authorization URL: '${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, default to all (shouldn't happen but be safe) + if (types.length === 0) { + return 'export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth;'; + } + + 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 +): string { + const requirements = analyzeSecuritySchemes(schemes); + + const parts: string[] = [ + '// ============================================================================', + '// Security Configuration Types - Grouped for better autocomplete', + '// ============================================================================', + '' + ]; + + // Only generate interfaces for required auth types + if (requirements.bearer) { + parts.push(renderBearerAuthInterface()); + parts.push(''); + } + + if (requirements.basic) { + parts.push(renderBasicAuthInterface()); + parts.push(''); + } + + if (requirements.apiKey) { + parts.push(renderApiKeyAuthInterface(requirements.apiKeySchemes)); + parts.push(''); + } + + if (requirements.oauth2) { + parts.push(renderOAuth2AuthInterface(requirements.oauth2Schemes)); + parts.push(''); + } + + // Add the AuthConfig union type + parts.push(renderAuthConfigType(requirements)); + + return parts.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 securityTypes = renderSecurityTypes(securitySchemes); + return `// ============================================================================ // Common Types - Shared across all HTTP client functions // ============================================================================ @@ -88,76 +463,7 @@ export interface TokenResponse { expiresIn?: number; } -// ============================================================================ -// 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') - 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; -} - -/** - * Union type for all authentication methods - provides autocomplete support - */ -export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; +${securityTypes} // ============================================================================ // Pagination Types diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 234bb18b..3206440c 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', @@ -247,6 +251,12 @@ export interface RenderHttpParameters { * When true, use unmarshalByStatusCode(json, statusCode) instead of unmarshal(json). */ includesStatusCodes?: boolean; + /** + * Security schemes extracted from the OpenAPI document. + * When provided, only auth types for these schemes will be generated. + * When undefined or empty, all auth types are generated for backward compatibility. + */ + securitySchemes?: ExtractedSecurityScheme[]; } export type SupportedProtocols = diff --git a/src/codegen/inputs/openapi/security.ts b/src/codegen/inputs/openapi/security.ts new file mode 100644 index 00000000..f2fcfc77 --- /dev/null +++ b/src/codegen/inputs/openapi/security.ts @@ -0,0 +1,322 @@ +/** + * 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; +type OpenAPIOperation = + | OpenAPIV3.OperationObject + | OpenAPIV2.OperationObject + | OpenAPIV3_1.OperationObject; + +/** + * 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; +} + +/** + * Extracts security requirement names from an OpenAPI operation. + * Returns the unique security scheme names that the operation requires. + */ +export function getOperationSecurityRequirements( + operation: OpenAPIOperation +): string[] { + const security = operation.security; + if (!security || security.length === 0) { + return []; + } + + const requirements = new Set(); + + for (const requirement of security) { + for (const schemeName of Object.keys(requirement)) { + requirements.add(schemeName); + } + } + + return Array.from(requirements); +} diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index 52a490e3..ada7fa1d 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,7 @@ export interface OAuth2Auth { /** * Union type for all authentication methods - provides autocomplete support */ -export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; +export type AuthConfig = ApiKeyAuth | OAuth2Auth; // ============================================================================ // Pagination Types diff --git a/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts b/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts new file mode 100644 index 00000000..89e29f68 --- /dev/null +++ b/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts @@ -0,0 +1,230 @@ +import { + renderHttpCommonTypes, + ExtractedSecurityScheme +} from '../../../../../../../src/codegen/generators/typescript/channels/protocols/http/fetch'; + +describe('HTTP Fetch Generator - Security Types', () => { + 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..ab14108e --- /dev/null +++ b/test/codegen/inputs/openapi/security.spec.ts @@ -0,0 +1,645 @@ +import {OpenAPIV3, OpenAPIV2} from 'openapi-types'; +import { + extractSecuritySchemes, + getOperationSecurityRequirements, + 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([]); + }); + }); + }); + + describe('getOperationSecurityRequirements', () => { + it('should extract security requirements from operation', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {}, + security: [ + { + bearerAuth: [] + } + ] + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).toEqual(['bearerAuth']); + }); + + it('should extract multiple security requirements', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {}, + security: [ + { + bearerAuth: [], + api_key: [] + } + ] + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).toContain('bearerAuth'); + expect(requirements).toContain('api_key'); + }); + + it('should extract security requirements with scopes', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {}, + security: [ + { + oauth2: ['read:pets', 'write:pets'] + } + ] + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).toEqual(['oauth2']); + }); + + it('should handle multiple security requirement objects (OR relationship)', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {}, + security: [ + {bearerAuth: []}, + {api_key: []}, + {oauth2: ['read:pets']} + ] + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).toContain('bearerAuth'); + expect(requirements).toContain('api_key'); + expect(requirements).toContain('oauth2'); + }); + + it('should return empty array when no security defined', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {} + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).toEqual([]); + }); + + it('should return empty array for empty security array', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {}, + security: [] + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).toEqual([]); + }); + + it('should handle empty security object (no auth required for operation)', () => { + const operation: OpenAPIV3.OperationObject = { + responses: {}, + security: [{}] + }; + + const requirements = getOperationSecurityRequirements(operation); + + expect(requirements).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..cc989cd4 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,7 @@ export interface OAuth2Auth { /** * Union type for all authentication methods - provides autocomplete support */ -export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; +export type AuthConfig = ApiKeyAuth | OAuth2Auth; // ============================================================================ // Pagination Types 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..77606c4a --- /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 interface has this as the default in the comment + router.post('/pet', (req, res) => { + receivedApiKey = req.headers['x-api-key'] as string; + res.setHeader('Content-Type', 'application/json'); + res.write(responsePet.marshal()); + res.end(); + }); + + return runWithServer(app, port, async () => { + 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:${port}`, + auth: { + type: 'apiKey', + key: 'my-secret-api-key' + // Uses default header name 'X-API-Key' 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 () => { + const requestPet = new APet({ + name: 'Fluffy', + photoUrls: [] + }); + + // Use the header name from the spec + await postAddPet({ + payload: requestPet, + server: `http://localhost:${port}`, + 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 () => { + 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:${port}`, + 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 () => { + 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:${port}`, + 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:${port}`, + // auth: { type: 'basic', username: 'user', password: 'pass' } // TypeScript Error! + // }); + // + // await postAddPet({ + // payload: requestPet, + // server: `http://localhost:${port}`, + // auth: { type: 'bearer', token: 'token' } // TypeScript Error! + // }); + + expect(true).toBe(true); // Placeholder - the real test is at compile time + }); + }); +}); From 1a6014cafa33620b2dff9d32269f94f68196368f Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sun, 8 Mar 2026 22:18:42 +0100 Subject: [PATCH 02/19] fix: use dynamic port assignment in HTTP runtime tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP runtime tests were using randomly generated ports which could cause EADDRINUSE errors when multiple tests ran in parallel and got the same random port. This fix: - Uses port 0 to let the OS assign an available port - Properly handles server errors with 'error' event listener - Passes the actual assigned port to test callbacks via a new parameter This ensures tests don't fail due to port collisions in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../http_client/api_auth.spec.ts | 20 ++--- .../http_client/authentication.spec.ts | 32 ++++---- .../request_reply/http_client/basics.spec.ts | 28 +++---- .../request_reply/http_client/hooks.spec.ts | 52 ++++++------ .../request_reply/http_client/methods.spec.ts | 68 ++++++++-------- .../request_reply/http_client/oauth2.spec.ts | 80 +++++++++---------- .../oauth2_client_credentials.spec.ts | 18 ++--- .../http_client/oauth2_implicit_flow.spec.ts | 18 ++--- .../http_client/oauth2_password_flow.spec.ts | 18 ++--- .../http_client/oauth2_refresh_token.spec.ts | 24 +++--- .../request_reply/http_client/openapi.spec.ts | 20 ++--- .../http_client/pagination.spec.ts | 48 +++++------ .../http_client/parameters-headers.spec.ts | 42 +++++----- .../request_reply/http_client/retry.spec.ts | 48 +++++------ .../http_client/security_schemes.spec.ts | 20 ++--- .../request_reply/http_client/test-utils.ts | 30 ++++--- 16 files changed, 287 insertions(+), 279 deletions(-) diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts index c69cd964..43f99206 100644 --- a/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts +++ b/test/runtime/typescript/test/channels/request_reply/http_client/api_auth.spec.ts @@ -26,10 +26,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, @@ -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 index 77606c4a..ea6d5576 100644 --- 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 @@ -74,7 +74,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const requestPet = new APet({ name: 'Fluffy', photoUrls: ['http://example.com/fluffy.jpg'] @@ -84,7 +84,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { // from the spec are documented in the generated interface await postAddPet({ payload: requestPet, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: 'my-secret-api-key' @@ -114,7 +114,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const requestPet = new APet({ name: 'Fluffy', photoUrls: [] @@ -123,7 +123,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { // Use the header name from the spec await postAddPet({ payload: requestPet, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'apiKey', key: 'my-secret-api-key', @@ -153,7 +153,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const requestPet = new APet({ name: 'Fluffy', photoUrls: ['http://example.com/fluffy.jpg'] @@ -162,7 +162,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { // With a pre-obtained token, oauth2 works const response = await postAddPet({ payload: requestPet, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', accessToken: 'pre-obtained-token' @@ -188,7 +188,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { res.end(); }); - return runWithServer(app, port, async () => { + return runWithServer(app, port, async (_server, actualPort) => { const requestPet = new APet({ name: 'Test', photoUrls: [] @@ -198,7 +198,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { // The generated interface documents these in the JSDoc await postAddPet({ payload: requestPet, - server: `http://localhost:${port}`, + server: `http://localhost:${actualPort}`, auth: { type: 'oauth2', accessToken: 'token', @@ -219,13 +219,13 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { // // await postAddPet({ // payload: requestPet, - // server: `http://localhost:${port}`, + // server: `http://localhost:${actualPort}`, // auth: { type: 'basic', username: 'user', password: 'pass' } // TypeScript Error! // }); // // await postAddPet({ // payload: requestPet, - // server: `http://localhost:${port}`, + // server: `http://localhost:${actualPort}`, // auth: { type: 'bearer', token: 'token' } // TypeScript Error! // }); 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); From dec8c7ba0da6ad30dc4b5cbf27088ee1507a11f9 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 9 Mar 2026 08:54:51 +0100 Subject: [PATCH 03/19] fix: ensure OAuth2 helper functions are always available in generated code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added renderOAuth2Stubs() to generate type-safe stub functions when OAuth2 is not needed, ensuring TypeScript compilation succeeds - Changed fallback AuthConfig to use 'never' type instead of union of all auth types when no recognized security schemes exist - Added AUTH_FEATURES.oauth2 runtime guards to generated function code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../channels/protocols/http/fetch.ts | 358 ++++++++++-------- 1 file changed, 204 insertions(+), 154 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 94c56e39..c12582a5 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -316,9 +316,10 @@ function renderAuthConfigType(requirements: AuthTypeRequirements): string { types.push('OAuth2Auth'); } - // If no types, default to all (shouldn't happen but be safe) + // 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 'export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth;'; + return '// No authentication types needed for this API\nexport type AuthConfig = never;'; } return `/** @@ -369,6 +370,194 @@ function renderSecurityTypes( 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. @@ -380,6 +569,7 @@ function renderSecurityTypes( export function renderHttpCommonTypes( securitySchemes?: ExtractedSecurityScheme[] ): string { + const requirements = analyzeSecuritySchemes(securitySchemes); const securityTypes = renderSecurityTypes(securitySchemes); return `// ============================================================================ @@ -465,6 +655,14 @@ export interface TokenResponse { ${securityTypes} +/** + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. + */ +const AUTH_FEATURES = { + oauth2: ${requirements.oauth2} +} as const; + // ============================================================================ // Pagination Types // ============================================================================ @@ -682,34 +880,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 */ @@ -899,126 +1069,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 */ @@ -1290,7 +1340,7 @@ function applyTypedHeaders( return headers; } - +${requirements.oauth2 ? renderOAuth2Helpers() : renderOAuth2Stubs()} // ============================================================================ // Generated HTTP Client Functions // ============================================================================`; @@ -1466,7 +1516,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); } @@ -1516,7 +1566,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; @@ -1524,7 +1574,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) { From bdba1cb034c903ed4fd0c7d558467afb539f6965 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 9 Mar 2026 08:56:05 +0100 Subject: [PATCH 04/19] wip --- .../generators/typescript/channels/types.ts | 6 - src/codegen/inputs/openapi/security.ts | 27 -- test/codegen/inputs/openapi/security.spec.ts | 99 ----- .../src/openapi/channels/http_client.ts | 321 ++++++++-------- .../src/request-reply/channels/http_client.ts | 363 +++++++++--------- 5 files changed, 349 insertions(+), 467 deletions(-) diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 3206440c..d30fd6b6 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -251,12 +251,6 @@ export interface RenderHttpParameters { * When true, use unmarshalByStatusCode(json, statusCode) instead of unmarshal(json). */ includesStatusCodes?: boolean; - /** - * Security schemes extracted from the OpenAPI document. - * When provided, only auth types for these schemes will be generated. - * When undefined or empty, all auth types are generated for backward compatibility. - */ - securitySchemes?: ExtractedSecurityScheme[]; } export type SupportedProtocols = diff --git a/src/codegen/inputs/openapi/security.ts b/src/codegen/inputs/openapi/security.ts index f2fcfc77..0a40aa46 100644 --- a/src/codegen/inputs/openapi/security.ts +++ b/src/codegen/inputs/openapi/security.ts @@ -53,10 +53,6 @@ type OpenAPIDocument = | OpenAPIV3.Document | OpenAPIV2.Document | OpenAPIV3_1.Document; -type OpenAPIOperation = - | OpenAPIV3.OperationObject - | OpenAPIV2.OperationObject - | OpenAPIV3_1.OperationObject; /** * Extracts security schemes from an OpenAPI document. @@ -297,26 +293,3 @@ function extractSwagger2OAuth2Flow( return result; } - -/** - * Extracts security requirement names from an OpenAPI operation. - * Returns the unique security scheme names that the operation requires. - */ -export function getOperationSecurityRequirements( - operation: OpenAPIOperation -): string[] { - const security = operation.security; - if (!security || security.length === 0) { - return []; - } - - const requirements = new Set(); - - for (const requirement of security) { - for (const schemeName of Object.keys(requirement)) { - requirements.add(schemeName); - } - } - - return Array.from(requirements); -} diff --git a/test/codegen/inputs/openapi/security.spec.ts b/test/codegen/inputs/openapi/security.spec.ts index ab14108e..46a5963b 100644 --- a/test/codegen/inputs/openapi/security.spec.ts +++ b/test/codegen/inputs/openapi/security.spec.ts @@ -1,7 +1,6 @@ import {OpenAPIV3, OpenAPIV2} from 'openapi-types'; import { extractSecuritySchemes, - getOperationSecurityRequirements, ExtractedSecurityScheme } from '../../../../src/codegen/inputs/openapi/security'; @@ -544,102 +543,4 @@ describe('OpenAPI Security Extraction', () => { }); }); }); - - describe('getOperationSecurityRequirements', () => { - it('should extract security requirements from operation', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {}, - security: [ - { - bearerAuth: [] - } - ] - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toEqual(['bearerAuth']); - }); - - it('should extract multiple security requirements', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {}, - security: [ - { - bearerAuth: [], - api_key: [] - } - ] - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toContain('bearerAuth'); - expect(requirements).toContain('api_key'); - }); - - it('should extract security requirements with scopes', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {}, - security: [ - { - oauth2: ['read:pets', 'write:pets'] - } - ] - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toEqual(['oauth2']); - }); - - it('should handle multiple security requirement objects (OR relationship)', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {}, - security: [ - {bearerAuth: []}, - {api_key: []}, - {oauth2: ['read:pets']} - ] - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toContain('bearerAuth'); - expect(requirements).toContain('api_key'); - expect(requirements).toContain('oauth2'); - }); - - it('should return empty array when no security defined', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {} - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toEqual([]); - }); - - it('should return empty array for empty security array', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {}, - security: [] - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toEqual([]); - }); - - it('should handle empty security object (no auth required for operation)', () => { - const operation: OpenAPIV3.OperationObject = { - responses: {}, - security: [{}] - }; - - const requirements = getOperationSecurityRequirements(operation); - - expect(requirements).toEqual([]); - }); - }); }); diff --git a/test/runtime/typescript/src/openapi/channels/http_client.ts b/test/runtime/typescript/src/openapi/channels/http_client.ts index cc989cd4..fa3d0ed3 100644 --- a/test/runtime/typescript/src/openapi/channels/http_client.ts +++ b/test/runtime/typescript/src/openapi/channels/http_client.ts @@ -148,6 +148,14 @@ export interface 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; + // ============================================================================ // Pagination Types // ============================================================================ @@ -365,34 +373,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 */ @@ -582,126 +562,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 */ @@ -974,6 +834,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 // ============================================================================ @@ -992,7 +999,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 +851,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 +1016,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 +1068,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 +1076,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 +1135,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 +1187,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 +1195,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 +1255,7 @@ async function putPingPutRequest(context: PutPingPutRequestContext): Promise Date: Mon, 9 Mar 2026 09:37:33 +0100 Subject: [PATCH 05/19] fix: address PR review comments for HTTP security types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ApiKeyAuth default mismatch: generated interface docs and runtime now use consistent spec-derived defaults instead of hardcoded 'X-API-Key' - Eliminate redundant analyzeSecuritySchemes call by passing pre-computed requirements to renderSecurityTypes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../channels/protocols/http/fetch.ts | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index c12582a5..a5a27892 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -332,9 +332,10 @@ export type AuthConfig = ${types.join(' | ')};`; * Generates the security configuration types based on extracted security schemes. */ function renderSecurityTypes( - schemes: ExtractedSecurityScheme[] | undefined + schemes: ExtractedSecurityScheme[] | undefined, + requirements?: AuthTypeRequirements ): string { - const requirements = analyzeSecuritySchemes(schemes); + const authRequirements = requirements ?? analyzeSecuritySchemes(schemes); const parts: string[] = [ '// ============================================================================', @@ -344,28 +345,28 @@ function renderSecurityTypes( ]; // Only generate interfaces for required auth types - if (requirements.bearer) { + if (authRequirements.bearer) { parts.push(renderBearerAuthInterface()); parts.push(''); } - if (requirements.basic) { + if (authRequirements.basic) { parts.push(renderBasicAuthInterface()); parts.push(''); } - if (requirements.apiKey) { - parts.push(renderApiKeyAuthInterface(requirements.apiKeySchemes)); + if (authRequirements.apiKey) { + parts.push(renderApiKeyAuthInterface(authRequirements.apiKeySchemes)); parts.push(''); } - if (requirements.oauth2) { - parts.push(renderOAuth2AuthInterface(requirements.oauth2Schemes)); + if (authRequirements.oauth2) { + parts.push(renderOAuth2AuthInterface(authRequirements.oauth2Schemes)); parts.push(''); } // Add the AuthConfig union type - parts.push(renderAuthConfigType(requirements)); + parts.push(renderAuthConfigType(authRequirements)); return parts.join('\n'); } @@ -570,7 +571,7 @@ export function renderHttpCommonTypes( securitySchemes?: ExtractedSecurityScheme[] ): string { const requirements = analyzeSecuritySchemes(securitySchemes); - const securityTypes = renderSecurityTypes(securitySchemes); + const securityTypes = renderSecurityTypes(securitySchemes, requirements); return `// ============================================================================ // Common Types - Shared across all HTTP client functions @@ -663,6 +664,15 @@ const AUTH_FEATURES = { oauth2: ${requirements.oauth2} } 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: '${requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyName || 'X-API-Key' : 'X-API-Key'}', + in: '${requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyIn || 'header' : 'header'}' as 'header' | 'query' | 'cookie' +} as const; + // ============================================================================ // Pagination Types // ============================================================================ @@ -855,14 +865,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; } From 6ef33d1e6f070dbe0509f7d83bbf5ce557456eff Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 9 Mar 2026 09:50:03 +0100 Subject: [PATCH 06/19] fix: escape OpenAPI spec values in generated TypeScript code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add escapeStringForCodeGen helper to escape special characters (backslashes, single quotes, backticks, dollar signs) in OpenAPI spec values before interpolating them into generated TypeScript. Prevents syntax errors when spec values contain characters like quotes in apiKeyName, tokenUrl, etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../channels/protocols/http/fetch.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index a5a27892..f9c886d6 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -9,6 +9,19 @@ import { // 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. */ @@ -119,14 +132,18 @@ function renderApiKeyAuthInterface( ? "'header' | 'query' | 'cookie'" : "'header' | 'query'"; + // Escape spec values for safe interpolation into generated code + const escapedDefaultName = escapeStringForCodeGen(defaultName); + const escapedDefaultIn = escapeStringForCodeGen(defaultIn); + return `/** * API key authentication configuration */ export interface ApiKeyAuth { type: 'apiKey'; key: string; - name?: string; // Name of the API key parameter (default: '${defaultName}') - in?: ${inType}; // Where to place the API key (default: '${defaultIn}') + name?: string; // Name of the API key parameter (default: '${escapedDefaultName}') + in?: ${inType}; // Where to place the API key (default: '${escapedDefaultIn}') }`; } @@ -206,7 +223,7 @@ function extractSchemeComments( if (scheme.openIdConnectUrl) { return { ...existing, - tokenUrlComment: `OpenID Connect URL: '${scheme.openIdConnectUrl}'` + tokenUrlComment: `OpenID Connect URL: '${escapeStringForCodeGen(scheme.openIdConnectUrl)}'` }; } @@ -220,10 +237,10 @@ function extractSchemeComments( return { tokenUrlComment: tokenUrl - ? `default: '${tokenUrl}'` + ? `default: '${escapeStringForCodeGen(tokenUrl)}'` : existing.tokenUrlComment, authorizationUrlComment: authUrl - ? ` Authorization URL: '${authUrl}'` + ? ` Authorization URL: '${escapeStringForCodeGen(authUrl)}'` : existing.authorizationUrlComment, scopesComment: formatScopesComment(allScopes) || existing.scopesComment }; @@ -669,8 +686,8 @@ const AUTH_FEATURES = { * These match the defaults documented in the ApiKeyAuth interface. */ const API_KEY_DEFAULTS = { - name: '${requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyName || 'X-API-Key' : 'X-API-Key'}', - in: '${requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyIn || 'header' : 'header'}' as 'header' | 'query' | 'cookie' + name: '${escapeStringForCodeGen(requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyName || 'X-API-Key' : 'X-API-Key')}', + in: '${escapeStringForCodeGen(requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyIn || 'header' : 'header')}' as 'header' | 'query' | 'cookie' } as const; // ============================================================================ From 8663c47f738a1cef807de6d92706d194809fb7c8 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 9 Mar 2026 10:19:15 +0100 Subject: [PATCH 07/19] refactor: extract shared API key defaults logic into helper function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review comment about duplicated logic between renderApiKeyAuthInterface and API_KEY_DEFAULTS template interpolation. Both now call getApiKeyDefaults() to ensure consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../channels/protocols/http/fetch.ts | 37 +++++++++++++------ .../src/openapi/channels/http_client.ts | 17 +++++++-- .../src/request-reply/channels/http_client.ts | 17 +++++++-- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index f9c886d6..708d61c2 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -112,20 +112,33 @@ export interface BasicAuth { }`; } +/** + * 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 { - // If there's exactly one apiKey scheme, we can provide defaults - let defaultName = 'X-API-Key'; - let defaultIn: string = 'header'; - - if (apiKeySchemes.length === 1) { - defaultName = apiKeySchemes[0].apiKeyName || defaultName; - defaultIn = apiKeySchemes[0].apiKeyIn || defaultIn; - } + const defaults = getApiKeyDefaults(apiKeySchemes); // For cookie support const inType = apiKeySchemes.some((s) => s.apiKeyIn === 'cookie') @@ -133,8 +146,8 @@ function renderApiKeyAuthInterface( : "'header' | 'query'"; // Escape spec values for safe interpolation into generated code - const escapedDefaultName = escapeStringForCodeGen(defaultName); - const escapedDefaultIn = escapeStringForCodeGen(defaultIn); + const escapedDefaultName = escapeStringForCodeGen(defaults.name); + const escapedDefaultIn = escapeStringForCodeGen(defaults.in); return `/** * API key authentication configuration @@ -686,8 +699,8 @@ const AUTH_FEATURES = { * These match the defaults documented in the ApiKeyAuth interface. */ const API_KEY_DEFAULTS = { - name: '${escapeStringForCodeGen(requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyName || 'X-API-Key' : 'X-API-Key')}', - in: '${escapeStringForCodeGen(requirements.apiKeySchemes.length === 1 ? requirements.apiKeySchemes[0].apiKeyIn || 'header' : 'header')}' as 'header' | 'query' | 'cookie' + name: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).name)}', + in: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).in)}' as 'header' | 'query' | 'cookie' } as const; // ============================================================================ diff --git a/test/runtime/typescript/src/openapi/channels/http_client.ts b/test/runtime/typescript/src/openapi/channels/http_client.ts index fa3d0ed3..06f1f8a9 100644 --- a/test/runtime/typescript/src/openapi/channels/http_client.ts +++ b/test/runtime/typescript/src/openapi/channels/http_client.ts @@ -156,6 +156,15 @@ 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 // ============================================================================ @@ -348,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; } diff --git a/test/runtime/typescript/src/request-reply/channels/http_client.ts b/test/runtime/typescript/src/request-reply/channels/http_client.ts index 8297360d..f4d913a8 100644 --- a/test/runtime/typescript/src/request-reply/channels/http_client.ts +++ b/test/runtime/typescript/src/request-reply/channels/http_client.ts @@ -173,6 +173,15 @@ 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: 'X-API-Key', + in: 'header' as 'header' | 'query' | 'cookie' +} as const; + // ============================================================================ // Pagination Types // ============================================================================ @@ -365,14 +374,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; } From 0118d2115f5759cd7990413b0c09213daf403f29 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 9 Mar 2026 10:50:13 +0100 Subject: [PATCH 08/19] fix: update tests for API key defaults from spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated code now uses 'api_key' as the default header name (extracted from the OpenAPI spec) instead of the generic 'X-API-Key'. Updated the runtime test to expect this correct header name and updated snapshots to reflect the new generated output. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../__snapshots__/channels.spec.ts.snap | 742 +++++++++--------- .../http_client/security_schemes.spec.ts | 6 +- 2 files changed, 392 insertions(+), 356 deletions(-) diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index ada7fa1d..5eab2593 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -150,6 +150,23 @@ export interface 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 // ============================================================================ @@ -342,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; } @@ -367,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 */ @@ -584,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 */ @@ -976,56 +847,203 @@ function applyTypedHeaders( return headers; } -// ============================================================================ -// Generated HTTP Client Functions -// ============================================================================ - -export interface PostAddPetContext extends HttpClientContext { - payload: Pet; - requestHeaders?: { marshal: () => string }; -} +/** + * 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; -async function postAddPet(context: PostAddPetContext): Promise> { - // Apply defaults - const config = { - path: '/pet', - server: 'localhost:3000', - ...context, - }; + 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; - // Validate OAuth2 config if present - if (config.auth?.type === 'oauth2') { - validateOAuth2Config(config.auth); + 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; } +} - // Build headers - let headers = context.requestHeaders - ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) - : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; +/** + * 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; - // Build URL - let url = \`\${config.server}\${config.path}\`; - url = applyQueryParams(config.queryParams, url); + const params = new URLSearchParams(); - // Apply pagination (can affect URL and/or headers) - const paginationResult = applyPagination(config.pagination, url, headers); - url = paginationResult.url; - headers = paginationResult.headers; + 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; + } - // Apply authentication - const authResult = applyAuth(config.auth, headers, url); - headers = authResult.headers; - url = authResult.url; + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } - // Prepare body - const body = context.payload?.marshal(); + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; - // Determine request function - const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + // 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'); + } - // Build request params - let requestParams: HttpRequestParams = { - url, + 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); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = context.payload?.marshal(); + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, method: 'POST', headers, body @@ -1046,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 */ @@ -2873,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 // ============================================================================ @@ -2890,7 +2926,7 @@ async function getPingRequest(context: GetPingRequestContext = {}): Promise { let receivedApiKey: string | undefined; // The spec defines: name: "api_key", in: "header" - // The generated interface has this as the default in the comment + // The generated code uses 'api_key' as the default header name (from spec) router.post('/pet', (req, res) => { - receivedApiKey = req.headers['x-api-key'] as string; + receivedApiKey = req.headers['api_key'] as string; res.setHeader('Content-Type', 'application/json'); res.write(responsePet.marshal()); res.end(); @@ -88,7 +88,7 @@ describe('HTTP Client - Security Schemes from OpenAPI', () => { auth: { type: 'apiKey', key: 'my-secret-api-key' - // Uses default header name 'X-API-Key' when not specified + // Uses default header name 'api_key' from spec when not specified } }); From 20fceeeec95c68e6b339a44ebf30abf062bfd9c2 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Mon, 9 Mar 2026 10:55:53 +0100 Subject: [PATCH 09/19] fix: resolve ESLint errors in fetch.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add curly braces after if condition - Remove multiple spaces before inline comments 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../typescript/channels/protocols/http/fetch.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 708d61c2..24906792 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -14,12 +14,14 @@ export {ExtractedSecurityScheme}; * Prevents syntax errors when OpenAPI spec values contain quotes, backticks, or template expressions. */ function escapeStringForCodeGen(value: string | undefined): string { - if (!value) return ''; + 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) + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/'/g, "\\'") // Escape single quotes + .replace(/`/g, '\\`') // Escape backticks + .replace(/\$/g, '\\$'); // Escape dollar signs (prevents ${} template evaluation) } /** From 6d80cef860f59b2879566122ad7989d6341024f1 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 21:21:32 +0100 Subject: [PATCH 10/19] refactor: rename ExtractedSecurityScheme to SecuritySchemeOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename for clearer separation between input layer and generator layer. The name "ExtractedSecurityScheme" implied input-layer concern, but this interface is used throughout generators as configuration options. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../channels/protocols/http/fetch.ts | 30 +++++++++--------- .../generators/typescript/channels/types.ts | 4 +-- src/codegen/inputs/openapi/security.ts | 31 ++++++++++--------- .../protocols/http/fetch-security.spec.ts | 20 ++++++------ test/codegen/inputs/openapi/security.spec.ts | 22 ++++++------- 5 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 24906792..17e1ece2 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -3,11 +3,11 @@ import {pascalCase} from '../../../utils'; import { ChannelFunctionTypes, RenderHttpParameters, - ExtractedSecurityScheme + SecuritySchemeOptions } from '../../types'; // Re-export for use by other modules -export {ExtractedSecurityScheme}; +export {SecuritySchemeOptions}; /** * Escapes special characters in strings that will be interpolated into generated code. @@ -32,15 +32,15 @@ interface AuthTypeRequirements { basic: boolean; apiKey: boolean; oauth2: boolean; - apiKeySchemes: ExtractedSecurityScheme[]; - oauth2Schemes: ExtractedSecurityScheme[]; + apiKeySchemes: SecuritySchemeOptions[]; + oauth2Schemes: SecuritySchemeOptions[]; } /** * Analyzes security schemes to determine which auth types are needed. */ function analyzeSecuritySchemes( - schemes: ExtractedSecurityScheme[] | undefined + schemes: SecuritySchemeOptions[] | undefined ): AuthTypeRequirements { // No schemes = backward compatibility mode, generate all types if (!schemes || schemes.length === 0) { @@ -118,7 +118,7 @@ export interface BasicAuth { * Extracts API key defaults from schemes. * If there's exactly one apiKey scheme, use its values; otherwise use standard defaults. */ -function getApiKeyDefaults(apiKeySchemes: ExtractedSecurityScheme[]): { +function getApiKeyDefaults(apiKeySchemes: SecuritySchemeOptions[]): { name: string; in: string; } { @@ -138,7 +138,7 @@ function getApiKeyDefaults(apiKeySchemes: ExtractedSecurityScheme[]): { * Generates the ApiKeyAuth interface with optional pre-populated defaults from spec. */ function renderApiKeyAuthInterface( - apiKeySchemes: ExtractedSecurityScheme[] + apiKeySchemes: SecuritySchemeOptions[] ): string { const defaults = getApiKeyDefaults(apiKeySchemes); @@ -166,7 +166,7 @@ export interface ApiKeyAuth { * Extracts the tokenUrl from OAuth2 flows. */ function extractTokenUrl( - flows: NonNullable + flows: NonNullable ): string | undefined { return ( flows.clientCredentials?.tokenUrl || @@ -179,7 +179,7 @@ function extractTokenUrl( * Extracts the authorizationUrl from OAuth2 flows. */ function extractAuthorizationUrl( - flows: NonNullable + flows: NonNullable ): string | undefined { return ( flows.implicit?.authorizationUrl || @@ -191,7 +191,7 @@ function extractAuthorizationUrl( * Collects all scopes from OAuth2 flows. */ function collectScopes( - flows: NonNullable + flows: NonNullable ): Set { const allScopes = new Set(); const flowTypes = [ @@ -232,7 +232,7 @@ function formatScopesComment(scopes: Set): string { * Extracts documentation comments from a single OAuth2 scheme. */ function extractSchemeComments( - scheme: ExtractedSecurityScheme, + scheme: SecuritySchemeOptions, existing: OAuth2DocComments ): OAuth2DocComments { if (scheme.openIdConnectUrl) { @@ -265,7 +265,7 @@ function extractSchemeComments( * Extracts documentation comments from OAuth2 schemes. */ function extractOAuth2DocComments( - oauth2Schemes: ExtractedSecurityScheme[] + oauth2Schemes: SecuritySchemeOptions[] ): OAuth2DocComments { const initial: OAuth2DocComments = { tokenUrlComment: @@ -284,7 +284,7 @@ function extractOAuth2DocComments( * Generates the OAuth2Auth interface with optional pre-populated values from spec. */ function renderOAuth2AuthInterface( - oauth2Schemes: ExtractedSecurityScheme[] + oauth2Schemes: SecuritySchemeOptions[] ): string { const {tokenUrlComment, authorizationUrlComment, scopesComment} = extractOAuth2DocComments(oauth2Schemes); @@ -364,7 +364,7 @@ export type AuthConfig = ${types.join(' | ')};`; * Generates the security configuration types based on extracted security schemes. */ function renderSecurityTypes( - schemes: ExtractedSecurityScheme[] | undefined, + schemes: SecuritySchemeOptions[] | undefined, requirements?: AuthTypeRequirements ): string { const authRequirements = requirements ?? analyzeSecuritySchemes(schemes); @@ -600,7 +600,7 @@ function renderOAuth2Helpers(): string { * When undefined/empty, all auth types are generated for backward compatibility. */ export function renderHttpCommonTypes( - securitySchemes?: ExtractedSecurityScheme[] + securitySchemes?: SecuritySchemeOptions[] ): string { const requirements = analyzeSecuritySchemes(securitySchemes); const securityTypes = renderSecurityTypes(securitySchemes, requirements); diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index d30fd6b6..0273d4e8 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -6,10 +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'; +import {SecuritySchemeOptions} from '../../../inputs/openapi/security'; // Re-export for convenience -export {ExtractedSecurityScheme}; +export {SecuritySchemeOptions}; 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 index 0a40aa46..1557d191 100644 --- a/src/codegen/inputs/openapi/security.ts +++ b/src/codegen/inputs/openapi/security.ts @@ -5,10 +5,13 @@ 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. + * Security scheme configuration options. + * + * Provides a normalized representation of security schemes from OpenAPI documents, + * supporting both OpenAPI 3.x securitySchemes and Swagger 2.0 securityDefinitions. + * This interface is used by generators to configure authentication in generated code. */ -export interface ExtractedSecurityScheme { +export interface SecuritySchemeOptions { /** The name/key of the security scheme as defined in the spec */ name: string; /** Security scheme type */ @@ -61,7 +64,7 @@ type OpenAPIDocument = */ export function extractSecuritySchemes( document: OpenAPIDocument -): ExtractedSecurityScheme[] { +): SecuritySchemeOptions[] { // Check if OpenAPI 3.x document if ('openapi' in document) { return extractOpenAPI3SecuritySchemes( @@ -82,13 +85,13 @@ export function extractSecuritySchemes( */ function extractOpenAPI3SecuritySchemes( document: OpenAPIV3.Document | OpenAPIV3_1.Document -): ExtractedSecurityScheme[] { +): SecuritySchemeOptions[] { const securitySchemes = document.components?.securitySchemes; if (!securitySchemes) { return []; } - const schemes: ExtractedSecurityScheme[] = []; + const schemes: SecuritySchemeOptions[] = []; for (const [name, scheme] of Object.entries(securitySchemes)) { // Skip $ref - should be dereferenced already @@ -112,7 +115,7 @@ function extractOpenAPI3SecuritySchemes( function extractOpenAPI3Scheme( name: string, scheme: OpenAPIV3.SecuritySchemeObject -): ExtractedSecurityScheme | undefined { +): SecuritySchemeOptions | undefined { switch (scheme.type) { case 'apiKey': return { @@ -154,8 +157,8 @@ function extractOpenAPI3Scheme( */ function extractOAuth2Flows( flows: OpenAPIV3.OAuth2SecurityScheme['flows'] -): ExtractedSecurityScheme['oauth2Flows'] { - const result: ExtractedSecurityScheme['oauth2Flows'] = {}; +): SecuritySchemeOptions['oauth2Flows'] { + const result: SecuritySchemeOptions['oauth2Flows'] = {}; if (flows.implicit) { result.implicit = { @@ -194,13 +197,13 @@ function extractOAuth2Flows( */ function extractSwagger2SecuritySchemes( document: OpenAPIV2.Document -): ExtractedSecurityScheme[] { +): SecuritySchemeOptions[] { const securityDefinitions = document.securityDefinitions; if (!securityDefinitions) { return []; } - const schemes: ExtractedSecurityScheme[] = []; + const schemes: SecuritySchemeOptions[] = []; for (const [name, definition] of Object.entries(securityDefinitions)) { const extracted = extractSwagger2Scheme(name, definition); @@ -218,7 +221,7 @@ function extractSwagger2SecuritySchemes( function extractSwagger2Scheme( name: string, definition: OpenAPIV2.SecuritySchemeObject -): ExtractedSecurityScheme | undefined { +): SecuritySchemeOptions | undefined { switch (definition.type) { case 'apiKey': return { @@ -254,8 +257,8 @@ function extractSwagger2Scheme( */ function extractSwagger2OAuth2Flow( definition: OpenAPIV2.SecuritySchemeOauth2 -): ExtractedSecurityScheme['oauth2Flows'] { - const result: ExtractedSecurityScheme['oauth2Flows'] = {}; +): SecuritySchemeOptions['oauth2Flows'] { + const result: SecuritySchemeOptions['oauth2Flows'] = {}; const scopes = definition.scopes || {}; switch (definition.flow) { diff --git a/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts b/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts index 89e29f68..524d10a3 100644 --- a/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts +++ b/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts @@ -1,12 +1,12 @@ import { renderHttpCommonTypes, - ExtractedSecurityScheme + SecuritySchemeOptions } from '../../../../../../../src/codegen/generators/typescript/channels/protocols/http/fetch'; describe('HTTP Fetch Generator - Security Types', () => { describe('renderHttpCommonTypes with security schemes', () => { it('should generate only apiKey auth type when only apiKey scheme defined', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'api_key', type: 'apiKey', @@ -35,7 +35,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should generate only bearer auth type when http bearer scheme defined', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'bearerAuth', type: 'http', @@ -54,7 +54,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should generate only basic auth type when http basic scheme defined', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'basicAuth', type: 'http', @@ -73,7 +73,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should generate only oauth2 auth type when oauth2 scheme defined', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'oauth2', type: 'oauth2', @@ -101,7 +101,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should generate multiple auth types when multiple schemes defined', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'api_key', type: 'apiKey', @@ -148,7 +148,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should generate auth interface with pre-populated apiKey name and location', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'petstore_api_key', type: 'apiKey', @@ -165,7 +165,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should generate OAuth2 auth with tokenUrl from implicit flow', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'oauth2', type: 'oauth2', @@ -185,7 +185,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should handle openIdConnect type', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'oidc', type: 'openIdConnect', @@ -201,7 +201,7 @@ describe('HTTP Fetch Generator - Security Types', () => { }); it('should deduplicate auth types when same type defined multiple times', () => { - const securitySchemes: ExtractedSecurityScheme[] = [ + const securitySchemes: SecuritySchemeOptions[] = [ { name: 'api_key_header', type: 'apiKey', diff --git a/test/codegen/inputs/openapi/security.spec.ts b/test/codegen/inputs/openapi/security.spec.ts index 46a5963b..de3515a7 100644 --- a/test/codegen/inputs/openapi/security.spec.ts +++ b/test/codegen/inputs/openapi/security.spec.ts @@ -1,7 +1,7 @@ import {OpenAPIV3, OpenAPIV2} from 'openapi-types'; import { extractSecuritySchemes, - ExtractedSecurityScheme + SecuritySchemeOptions } from '../../../../src/codegen/inputs/openapi/security'; describe('OpenAPI Security Extraction', () => { @@ -26,7 +26,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'api_key', type: 'apiKey', apiKeyName: 'X-API-Key', @@ -53,7 +53,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'api_key', type: 'apiKey', apiKeyName: 'api_key', @@ -80,7 +80,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'session', type: 'apiKey', apiKeyName: 'session_id', @@ -107,7 +107,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'bearerAuth', type: 'http', httpScheme: 'bearer', @@ -133,7 +133,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'basicAuth', type: 'http', httpScheme: 'basic' @@ -166,7 +166,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'oauth2', type: 'oauth2', oauth2Flows: { @@ -297,7 +297,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'oidc', type: 'openIdConnect', openIdConnectUrl: @@ -386,7 +386,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'api_key', type: 'apiKey', apiKeyName: 'api_key', @@ -409,7 +409,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'basicAuth', type: 'http', httpScheme: 'basic' @@ -437,7 +437,7 @@ describe('OpenAPI Security Extraction', () => { const schemes = extractSecuritySchemes(document); expect(schemes).toHaveLength(1); - expect(schemes[0]).toEqual({ + expect(schemes[0]).toEqual({ name: 'petstore_auth', type: 'oauth2', oauth2Flows: { From 12920150ab6dadd797c8cb7f756c5f70a0f95c82 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 21:35:40 +0100 Subject: [PATCH 11/19] fix: resolve PR review comments on security scheme handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix empty schemes array being treated as backward compatibility mode Changed condition from `!schemes || schemes.length === 0` to just `!schemes` to distinguish undefined (backward compat for AsyncAPI) from [] (OpenAPI spec with no security schemes → AuthConfig = never) - Add */ escaping to prevent JSDoc comment injection Spec values like tokenUrl containing */ could prematurely terminate JSDoc comments, causing subsequent text to be parsed as executable TypeScript code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../typescript/channels/protocols/http/fetch.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 17e1ece2..8a7473b8 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -21,7 +21,8 @@ function escapeStringForCodeGen(value: string | undefined): string { .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/'/g, "\\'") // Escape single quotes .replace(/`/g, '\\`') // Escape backticks - .replace(/\$/g, '\\$'); // Escape dollar signs (prevents ${} template evaluation) + .replace(/\$/g, '\\$') // Escape dollar signs (prevents ${} template evaluation) + .replace(/\*\//g, '*\\/'); // Escape */ to prevent JSDoc comment injection } /** @@ -42,8 +43,9 @@ interface AuthTypeRequirements { function analyzeSecuritySchemes( schemes: SecuritySchemeOptions[] | undefined ): AuthTypeRequirements { - // No schemes = backward compatibility mode, generate all types - if (!schemes || schemes.length === 0) { + // undefined = backward compatibility mode (non-OpenAPI callers), generate all types + // [] = OpenAPI spec with no security schemes, generate AuthConfig = never + if (!schemes) { return { bearer: true, basic: true, From bc3fa7dcc32cdc9d46b6903958d180da3fb3c41b Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 21:39:23 +0100 Subject: [PATCH 12/19] fix: generate all auth types when empty security schemes array provided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When renderHttpCommonTypes receives an empty array [], it should generate all auth types for backward compatibility, the same as when no argument is provided. This allows users to manually configure auth even if no security schemes are defined in the OpenAPI spec. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../generators/typescript/channels/protocols/http/fetch.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index 8a7473b8..b4a9fc8d 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -43,9 +43,9 @@ interface AuthTypeRequirements { function analyzeSecuritySchemes( schemes: SecuritySchemeOptions[] | undefined ): AuthTypeRequirements { - // undefined = backward compatibility mode (non-OpenAPI callers), generate all types - // [] = OpenAPI spec with no security schemes, generate AuthConfig = never - if (!schemes) { + // undefined or empty array = backward compatibility mode, generate all types + // This allows users to manually configure auth even if no schemes are defined + if (!schemes || schemes.length === 0) { return { bearer: true, basic: true, From 39a3d2caf43723d6c4717fd14e95916933c759e4 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 21:44:16 +0100 Subject: [PATCH 13/19] fix: handle Node.js deprecation warnings in init tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The init command tests were failing on CI because Node.js deprecation warnings (e.g., punycode module) were being captured in stderr, causing assertions expecting empty stderr to fail. Added a helper function isStderrEmpty() that filters out known Node.js deprecation warnings, making the tests more robust across different Node.js versions and environments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/commands/init.spec.ts | 80 +++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/test/commands/init.spec.ts b/test/commands/init.spec.ts index 6070f22e..5bf110d0 100644 --- a/test/commands/init.spec.ts +++ b/test/commands/init.spec.ts @@ -5,12 +5,30 @@ jest.mock('inquirer', () => ({ prompt: jest.fn() })); +/** + * Helper to check if stderr only contains expected Node.js deprecation warnings. + * Node.js may output deprecation warnings (e.g., punycode) that we should ignore. + */ +function isStderrEmpty(stderr: string): boolean { + if (!stderr) return true; + // Filter out Node.js deprecation warnings + const lines = stderr.split('\n').filter(line => { + const trimmed = line.trim(); + if (!trimmed) return false; + // Ignore Node.js deprecation warnings + if (trimmed.includes('DeprecationWarning:')) return false; + if (trimmed.includes('--trace-deprecation')) return false; + return true; + }); + return lines.length === 0; +} + describe('init', () => { describe('configuration types', () => { it('should generate esm configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -18,7 +36,7 @@ describe('init', () => { it('should generate typescript configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=ts --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -26,7 +44,7 @@ describe('init', () => { it('should generate json configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -34,7 +52,7 @@ describe('init', () => { it('should generate yaml configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -44,7 +62,7 @@ describe('init', () => { it('should generate configuration with headers', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -52,7 +70,7 @@ describe('init', () => { it('should generate configuration with payloads', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -60,7 +78,7 @@ describe('init', () => { it('should generate configuration with parameters', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-parameters`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -68,7 +86,7 @@ describe('init', () => { it('should generate configuration with channels', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -76,7 +94,7 @@ describe('init', () => { it('should generate configuration with client', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-client`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -84,7 +102,7 @@ describe('init', () => { it('should generate configuration with all include flags', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers --include-payloads --include-parameters --include-channels --include-client`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -94,7 +112,7 @@ describe('init', () => { it('should handle asyncapi input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -102,7 +120,7 @@ describe('init', () => { it('should handle openapi input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -112,7 +130,7 @@ describe('init', () => { it('should use custom config name', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --config-name=my-custom-config --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -120,7 +138,7 @@ describe('init', () => { it('should use custom output directory', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./custom-output' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -148,28 +166,28 @@ describe('init', () => { it('should include correct language in configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('"language": "typescript"'); }); it('should include schema reference in JSON configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('"$schema": "https://raw.githubusercontent.com/the-codegen-project/cli/main/schemas/configuration-schema-0.json"'); }); it('should include schema reference in YAML configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('# yaml-language-server: $schema=https://raw.githubusercontent.com/the-codegen-project/cli/main/schemas/configuration-schema-0.json'); }); it('should have empty generators array when no include flags are specified', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('"generators": []'); }); }); @@ -201,7 +219,7 @@ describe('init', () => { it('should only include TypeScript-specific generators for TypeScript language', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('"generators"'); expect(stdout).toContain('"language": "typescript"'); }); @@ -209,7 +227,7 @@ describe('init', () => { it('should only include AsyncAPI-specific generators for AsyncAPI input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('"inputType": "asyncapi"'); expect(stdout).toContain('"generators"'); }); @@ -217,7 +235,7 @@ describe('init', () => { it('should handle OpenAPI with TypeScript but no AsyncAPI-specific features', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('"inputType": "openapi"'); expect(stdout).toContain('"generators": []'); // No AsyncAPI-specific generators should be added }); @@ -227,7 +245,7 @@ describe('init', () => { it('should accept gitignore-generated flag', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -235,7 +253,7 @@ describe('init', () => { it('should work without gitignore-generated flag', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -243,7 +261,7 @@ describe('init', () => { it('should accept gitignore-generated with payloads generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -251,7 +269,7 @@ describe('init', () => { it('should accept gitignore-generated with channels generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -259,7 +277,7 @@ describe('init', () => { it('should accept gitignore-generated with headers generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -267,7 +285,7 @@ describe('init', () => { it('should accept gitignore-generated with parameters generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-parameters --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -275,7 +293,7 @@ describe('init', () => { it('should accept gitignore-generated with client generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-client --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -283,7 +301,7 @@ describe('init', () => { it('should accept gitignore-generated with all generators', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads --include-channels --include-headers --include-parameters --include-client --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -313,14 +331,14 @@ describe('init', () => { it('should handle gitignore-generated with OpenAPI input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); it('should handle gitignore-generated with custom output directory', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./custom-output' --no-output --include-payloads --gitignore-generated`); expect(error).toBeUndefined(); - expect(stderr).toEqual(''); + expect(isStderrEmpty(stderr)).toBe(true); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); }); From 184235c0a5aab8a25dddb15a1ca18225b158a4ef Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 21:48:32 +0100 Subject: [PATCH 14/19] fix: add curly braces to satisfy ESLint curly rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes linting errors in test/commands/init.spec.ts where if statements were missing curly braces around their single-line return statements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/commands/init.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/commands/init.spec.ts b/test/commands/init.spec.ts index 5bf110d0..4f99f995 100644 --- a/test/commands/init.spec.ts +++ b/test/commands/init.spec.ts @@ -10,14 +10,14 @@ jest.mock('inquirer', () => ({ * Node.js may output deprecation warnings (e.g., punycode) that we should ignore. */ function isStderrEmpty(stderr: string): boolean { - if (!stderr) return true; + if (!stderr) {return true;} // Filter out Node.js deprecation warnings const lines = stderr.split('\n').filter(line => { const trimmed = line.trim(); - if (!trimmed) return false; + if (!trimmed) {return false;} // Ignore Node.js deprecation warnings - if (trimmed.includes('DeprecationWarning:')) return false; - if (trimmed.includes('--trace-deprecation')) return false; + if (trimmed.includes('DeprecationWarning:')) {return false;} + if (trimmed.includes('--trace-deprecation')) {return false;} return true; }); return lines.length === 0; From 840f850d7b8b1e8f753017e144a00de79d0fe430 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 22:17:32 +0100 Subject: [PATCH 15/19] wip --- test/commands/init.spec.ts | 80 +++++++++++++++----------------------- test/setup.js | 6 +++ 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/test/commands/init.spec.ts b/test/commands/init.spec.ts index 4f99f995..6070f22e 100644 --- a/test/commands/init.spec.ts +++ b/test/commands/init.spec.ts @@ -5,30 +5,12 @@ jest.mock('inquirer', () => ({ prompt: jest.fn() })); -/** - * Helper to check if stderr only contains expected Node.js deprecation warnings. - * Node.js may output deprecation warnings (e.g., punycode) that we should ignore. - */ -function isStderrEmpty(stderr: string): boolean { - if (!stderr) {return true;} - // Filter out Node.js deprecation warnings - const lines = stderr.split('\n').filter(line => { - const trimmed = line.trim(); - if (!trimmed) {return false;} - // Ignore Node.js deprecation warnings - if (trimmed.includes('DeprecationWarning:')) {return false;} - if (trimmed.includes('--trace-deprecation')) {return false;} - return true; - }); - return lines.length === 0; -} - describe('init', () => { describe('configuration types', () => { it('should generate esm configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -36,7 +18,7 @@ describe('init', () => { it('should generate typescript configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=ts --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -44,7 +26,7 @@ describe('init', () => { it('should generate json configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -52,7 +34,7 @@ describe('init', () => { it('should generate yaml configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -62,7 +44,7 @@ describe('init', () => { it('should generate configuration with headers', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -70,7 +52,7 @@ describe('init', () => { it('should generate configuration with payloads', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -78,7 +60,7 @@ describe('init', () => { it('should generate configuration with parameters', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-parameters`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -86,7 +68,7 @@ describe('init', () => { it('should generate configuration with channels', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -94,7 +76,7 @@ describe('init', () => { it('should generate configuration with client', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-client`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -102,7 +84,7 @@ describe('init', () => { it('should generate configuration with all include flags', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers --include-payloads --include-parameters --include-channels --include-client`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -112,7 +94,7 @@ describe('init', () => { it('should handle asyncapi input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -120,7 +102,7 @@ describe('init', () => { it('should handle openapi input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -130,7 +112,7 @@ describe('init', () => { it('should use custom config name', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --config-name=my-custom-config --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -138,7 +120,7 @@ describe('init', () => { it('should use custom output directory', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./custom-output' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -166,28 +148,28 @@ describe('init', () => { it('should include correct language in configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('"language": "typescript"'); }); it('should include schema reference in JSON configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('"$schema": "https://raw.githubusercontent.com/the-codegen-project/cli/main/schemas/configuration-schema-0.json"'); }); it('should include schema reference in YAML configuration', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('# yaml-language-server: $schema=https://raw.githubusercontent.com/the-codegen-project/cli/main/schemas/configuration-schema-0.json'); }); it('should have empty generators array when no include flags are specified', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('"generators": []'); }); }); @@ -219,7 +201,7 @@ describe('init', () => { it('should only include TypeScript-specific generators for TypeScript language', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('"generators"'); expect(stdout).toContain('"language": "typescript"'); }); @@ -227,7 +209,7 @@ describe('init', () => { it('should only include AsyncAPI-specific generators for AsyncAPI input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('"inputType": "asyncapi"'); expect(stdout).toContain('"generators"'); }); @@ -235,7 +217,7 @@ describe('init', () => { it('should handle OpenAPI with TypeScript but no AsyncAPI-specific features', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('"inputType": "openapi"'); expect(stdout).toContain('"generators": []'); // No AsyncAPI-specific generators should be added }); @@ -245,7 +227,7 @@ describe('init', () => { it('should accept gitignore-generated flag', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -253,7 +235,7 @@ describe('init', () => { it('should work without gitignore-generated flag', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -261,7 +243,7 @@ describe('init', () => { it('should accept gitignore-generated with payloads generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -269,7 +251,7 @@ describe('init', () => { it('should accept gitignore-generated with channels generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -277,7 +259,7 @@ describe('init', () => { it('should accept gitignore-generated with headers generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -285,7 +267,7 @@ describe('init', () => { it('should accept gitignore-generated with parameters generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-parameters --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -293,7 +275,7 @@ describe('init', () => { it('should accept gitignore-generated with client generator', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-client --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -301,7 +283,7 @@ describe('init', () => { it('should accept gitignore-generated with all generators', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-payloads --include-channels --include-headers --include-parameters --include-client --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).not.toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); @@ -331,14 +313,14 @@ describe('init', () => { it('should handle gitignore-generated with OpenAPI input type', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); it('should handle gitignore-generated with custom output directory', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./custom-output' --no-output --include-payloads --gitignore-generated`); expect(error).toBeUndefined(); - expect(isStderrEmpty(stderr)).toBe(true); + expect(stderr).toEqual(''); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); }); diff --git a/test/setup.js b/test/setup.js index 7642d7b6..9780fb17 100644 --- a/test/setup.js +++ b/test/setup.js @@ -3,3 +3,9 @@ // eslint-disable-next-line no-undef process.env.CODEGEN_TELEMETRY_DISABLED = '1'; +// Suppress Node.js deprecation warnings (e.g., DEP0040 for punycode) +// These come from transitive dependencies (tr46 via node-fetch via @asyncapi/parser) +// and pollute stderr in CLI tests that capture output +// eslint-disable-next-line no-undef +process.noDeprecation = true; + From a8084d7a4979e8da1471fd6bf34d915f0f211b1c Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 22:21:30 +0100 Subject: [PATCH 16/19] fix: escape OAuth2 scope names in generated JSDoc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope names from OpenAPI specs are now escaped using escapeStringForCodeGen to prevent */ sequences from prematurely closing JSDoc comments in generated TypeScript code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../generators/typescript/channels/protocols/http/fetch.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index b4a9fc8d..fb291a69 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -225,7 +225,10 @@ function formatScopesComment(scopes: Set): string { if (scopes.size === 0) { return ''; } - const scopeList = Array.from(scopes).slice(0, 3).join(', '); + const scopeList = Array.from(scopes) + .slice(0, 3) + .map((scope) => escapeStringForCodeGen(scope)) + .join(', '); const suffix = scopes.size > 3 ? '...' : ''; return ` Available: ${scopeList}${suffix}`; } From 3e525d814debc75df3da10037c00a6566a5bb944 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 22:28:51 +0100 Subject: [PATCH 17/19] rework fetch --- .../channels/protocols/http/fetch.ts | 336 +++++++++--------- 1 file changed, 164 insertions(+), 172 deletions(-) diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts index fb291a69..9b49f3b1 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts @@ -414,27 +414,23 @@ function renderSecurityTypes( * 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'); + return ` +// 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; }`; } /** @@ -442,158 +438,154 @@ function renderOAuth2Stubs(): string { * 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 ` +/** + * 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); +} - return code.join('\n'); +/** + * 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); +}`; } /** From 2cc39f019f215a3104fc54ac04e133b6127ae2d5 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 22:46:29 +0100 Subject: [PATCH 18/19] refactor code --- .../generators/typescript/channels/openapi.ts | 2 +- .../channels/protocols/http/client.ts | 285 +++ .../channels/protocols/http/common-types.ts | 812 ++++++++ .../channels/protocols/http/fetch.ts | 1663 ----------------- .../channels/protocols/http/index.ts | 20 +- .../channels/protocols/http/security.ts | 573 ++++++ .../protocols/http/fetch-security.spec.ts | 2 +- 7 files changed, 1690 insertions(+), 1667 deletions(-) create mode 100644 src/codegen/generators/typescript/channels/protocols/http/client.ts create mode 100644 src/codegen/generators/typescript/channels/protocols/http/common-types.ts delete mode 100644 src/codegen/generators/typescript/channels/protocols/http/fetch.ts create mode 100644 src/codegen/generators/typescript/channels/protocols/http/security.ts diff --git a/src/codegen/generators/typescript/channels/openapi.ts b/src/codegen/generators/typescript/channels/openapi.ts index 2099bd9a..a42c2506 100644 --- a/src/codegen/generators/typescript/channels/openapi.ts +++ b/src/codegen/generators/typescript/channels/openapi.ts @@ -16,7 +16,7 @@ import {collectProtocolDependencies} from './utils'; import { renderHttpFetchClient, renderHttpCommonTypes -} from './protocols/http/fetch'; +} from './protocols/http'; import {getMessageTypeAndModule} from './utils'; import {pascalCase} from '../utils'; import {createMissingInputDocumentError} from '../../../errors'; diff --git a/src/codegen/generators/typescript/channels/protocols/http/client.ts b/src/codegen/generators/typescript/channels/protocols/http/client.ts new file mode 100644 index 00000000..44ac8da6 --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/client.ts @@ -0,0 +1,285 @@ +/** + * Generates HTTP client functions for individual API operations. + * Each operation gets a typed function with request/response handling. + */ +import {HttpRenderType} from '../../../../../types'; +import {pascalCase} from '../../../utils'; +import {ChannelFunctionTypes, RenderHttpParameters} from '../../types'; + +/** + * Renders an HTTP fetch client function for a specific API operation. + */ +export function renderHttpFetchClient({ + requestTopic, + requestMessageType, + requestMessageModule, + replyMessageType, + replyMessageModule, + channelParameters, + method, + servers = [], + subName = pascalCase(requestTopic), + functionName = `${method.toLowerCase()}${subName}`, + includesStatusCodes = false +}: RenderHttpParameters): HttpRenderType { + const messageType = requestMessageModule + ? `${requestMessageModule}.${requestMessageType}` + : requestMessageType; + const replyType = replyMessageModule + ? `${replyMessageModule}.${replyMessageType}` + : replyMessageType; + + // Generate context interface name + const contextInterfaceName = `${pascalCase(functionName)}Context`; + + // Determine if operation has path parameters + const hasParameters = channelParameters !== undefined; + + // Generate the context interface (extends HttpClientContext) + const contextInterface = generateContextInterface( + contextInterfaceName, + messageType, + hasParameters, + method + ); + + // Generate the function implementation + const functionCode = generateFunctionImplementation({ + functionName, + contextInterfaceName, + replyType, + replyMessageModule, + replyMessageType, + messageType, + requestTopic, + hasParameters, + method, + servers, + includesStatusCodes + }); + + const code = `${contextInterface} + +${functionCode}`; + + return { + messageType, + replyType, + code, + functionName, + dependencies: [ + `import { URLSearchParams, URL } from 'url';`, + `import * as NodeFetch from 'node-fetch';` + ], + functionType: ChannelFunctionTypes.HTTP_CLIENT + }; +} + +/** + * Generate the context interface for an HTTP operation + */ +function generateContextInterface( + interfaceName: string, + messageType: string | undefined, + hasParameters: boolean, + method: string +): string { + const fields: string[] = []; + + // Add payload field for methods that have a body + if (messageType && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) { + fields.push(` payload: ${messageType};`); + } + + // Add parameters field if operation has path parameters + if (hasParameters) { + fields.push( + ` parameters: { getChannelWithParameters: (path: string) => string };` + ); + } + + // Add requestHeaders field (optional) for operations that support typed headers + // This is always optional since headers can also be passed via additionalHeaders + fields.push(` requestHeaders?: { marshal: () => string };`); + + const fieldsStr = fields.length > 0 ? `\n${fields.join('\n')}\n` : ''; + + return `export interface ${interfaceName} extends HttpClientContext {${fieldsStr}}`; +} + +/** + * Generate the function implementation + */ +function generateFunctionImplementation(params: { + functionName: string; + contextInterfaceName: string; + replyType: string; + replyMessageModule: string | undefined; + replyMessageType: string; + messageType: string | undefined; + requestTopic: string; + hasParameters: boolean; + method: string; + servers: string[]; + includesStatusCodes: boolean; +}): string { + const { + functionName, + contextInterfaceName, + replyType, + replyMessageModule, + replyMessageType, + messageType, + requestTopic, + hasParameters, + method, + servers, + includesStatusCodes + } = params; + + const defaultServer = servers[0] ?? "'localhost:3000'"; + const hasBody = + messageType && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()); + + // Generate URL building code + const urlBuildCode = hasParameters + ? `let url = buildUrlWithParameters(config.server, '${requestTopic}', context.parameters);` + : 'let url = `${config.server}${config.path}`;'; + + // Generate headers initialization + const headersInit = `let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;`; + + // Generate body preparation + const bodyPrep = hasBody + ? `const body = context.payload?.marshal();` + : `const body = undefined;`; + + // Generate response parsing + // Use unmarshalByStatusCode if the payload is a union type with status code support + let responseParseCode: string; + if (replyMessageModule) { + responseParseCode = includesStatusCodes + ? `const responseData = ${replyMessageModule}.unmarshalByStatusCode(rawData, response.status);` + : `const responseData = ${replyMessageModule}.unmarshal(rawData);`; + } else { + responseParseCode = `const responseData = ${replyMessageType}.unmarshal(rawData);`; + } + + // Generate default context for optional context parameter + const contextDefault = !hasBody && !hasParameters ? ' = {}' : ''; + + return `async function ${functionName}(context: ${contextInterfaceName}${contextDefault}): Promise> { + // Apply defaults + const config = { + path: '${requestTopic}', + server: ${defaultServer}, + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + ${headersInit} + + // Build URL + ${urlBuildCode} + 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); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + ${bodyPrep} + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: '${method}', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + 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; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + ${responseParseCode} + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse<${replyType}> = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, ${functionName}), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +}`; +} diff --git a/src/codegen/generators/typescript/channels/protocols/http/common-types.ts b/src/codegen/generators/typescript/channels/protocols/http/common-types.ts new file mode 100644 index 00000000..500e5636 --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/common-types.ts @@ -0,0 +1,812 @@ +/** + * Generates common types and helper functions shared across all HTTP client functions. + * This module renders the shared infrastructure code that is included once per generation. + */ +import {SecuritySchemeOptions} from '../../types'; +import { + analyzeSecuritySchemes, + escapeStringForCodeGen, + getApiKeyDefaults, + renderOAuth2Helpers, + renderOAuth2Stubs, + renderSecurityTypes +} from './security'; + +/** + * 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( + securitySchemes?: SecuritySchemeOptions[] +): string { + const requirements = analyzeSecuritySchemes(securitySchemes); + const securityTypes = renderSecurityTypes(securitySchemes, requirements); + + return `// ============================================================================ +// Common Types - Shared across all HTTP client functions +// ============================================================================ + +/** + * Standard HTTP response interface that wraps fetch-like responses + */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers?: Headers | Record; + json: () => Record | Promise>; +} + +/** + * Pagination info extracted from response + */ +export interface PaginationInfo { + /** Total number of items (if available from headers like X-Total-Count) */ + totalCount?: number; + /** Total number of pages (if available) */ + totalPages?: number; + /** Current page/offset */ + currentOffset?: number; + /** Items per page */ + limit?: number; + /** Next cursor (for cursor-based pagination) */ + nextCursor?: string; + /** Previous cursor */ + prevCursor?: string; + /** Whether there are more items */ + hasMore?: boolean; +} + +/** + * Rich response wrapper returned by HTTP client functions + */ +export interface HttpClientResponse { + /** The deserialized response payload */ + data: T; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Response headers */ + headers: Record; + /** Raw JSON response before deserialization */ + rawData: Record; + /** Pagination info extracted from response (if applicable) */ + pagination?: PaginationInfo; + /** Fetch the next page (if pagination is configured and more data exists) */ + getNextPage?: () => Promise>; + /** Fetch the previous page (if pagination is configured) */ + getPrevPage?: () => Promise>; + /** Check if there's a next page */ + hasNextPage?: () => boolean; + /** Check if there's a previous page */ + hasPrevPage?: () => boolean; +} + +/** + * HTTP request parameters passed to the request hook + */ +export interface HttpRequestParams { + url: string; + headers?: Record; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; + credentials?: RequestCredentials; + body?: any; +} + +/** + * Token response structure for OAuth2 flows + */ +export interface TokenResponse { + accessToken: string; + refreshToken?: string; + expiresIn?: number; +} + +${securityTypes} + +/** + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. + */ +const AUTH_FEATURES = { + oauth2: ${requirements.oauth2} +} 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: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).name)}', + in: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).in)}' as 'header' | 'query' | 'cookie' +} as const; + +// ============================================================================ +// Pagination Types +// ============================================================================ + +/** + * Where to place pagination parameters + */ +export type PaginationLocation = 'query' | 'header'; + +/** + * Offset-based pagination configuration + */ +export interface OffsetPagination { + type: 'offset'; + in?: PaginationLocation; // Where to place params (default: 'query') + offset: number; + limit: number; + offsetParam?: string; // Param name for offset (default: 'offset' for query, 'X-Offset' for header) + limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) +} + +/** + * Cursor-based pagination configuration + */ +export interface CursorPagination { + type: 'cursor'; + in?: PaginationLocation; // Where to place params (default: 'query') + cursor?: string; + limit?: number; + cursorParam?: string; // Param name for cursor (default: 'cursor' for query, 'X-Cursor' for header) + limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) +} + +/** + * Page-based pagination configuration + */ +export interface PagePagination { + type: 'page'; + in?: PaginationLocation; // Where to place params (default: 'query') + page: number; + pageSize: number; + pageParam?: string; // Param name for page (default: 'page' for query, 'X-Page' for header) + pageSizeParam?: string; // Param name for page size (default: 'pageSize' for query, 'X-Page-Size' for header) +} + +/** + * Range-based pagination (typically used with headers) + * Follows RFC 7233 style: Range: items=0-24 + */ +export interface RangePagination { + type: 'range'; + in?: 'header'; // Range pagination is typically header-only + start: number; + end: number; + unit?: string; // Range unit (default: 'items') + rangeHeader?: string; // Header name (default: 'Range') +} + +/** + * Union type for all pagination methods + */ +export type PaginationConfig = OffsetPagination | CursorPagination | PagePagination | RangePagination; + +// ============================================================================ +// Retry Configuration +// ============================================================================ + +/** + * Retry policy configuration for failed requests + */ +export interface RetryConfig { + maxRetries?: number; // Maximum number of retry attempts (default: 3) + initialDelayMs?: number; // Initial delay before first retry (default: 1000) + maxDelayMs?: number; // Maximum delay between retries (default: 30000) + backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) + retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) + retryOnNetworkError?: boolean; // Retry on network errors (default: true) + onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry +} + +// ============================================================================ +// Hooks Configuration - Extensible callback system +// ============================================================================ + +/** + * Hooks for customizing HTTP client behavior + */ +export interface HttpHooks { + /** + * Called before each request to transform/modify the request parameters + * Return modified params or undefined to use original + */ + beforeRequest?: (params: HttpRequestParams) => HttpRequestParams | Promise; + + /** + * The actual request implementation - allows swapping fetch for axios, etc. + * Default: uses node-fetch + */ + makeRequest?: (params: HttpRequestParams) => Promise; + + /** + * Called after each response for logging, metrics, etc. + * Can transform the response before it's processed + */ + afterResponse?: (response: HttpResponse, params: HttpRequestParams) => HttpResponse | Promise; + + /** + * Called on request error for logging, error transformation, etc. + */ + onError?: (error: Error, params: HttpRequestParams) => Error | Promise; +} + +// ============================================================================ +// Common Request Context +// ============================================================================ + +/** + * Base context shared by all HTTP client functions + */ +export interface HttpClientContext { + server?: string; + path?: string; + + // Authentication - grouped for better autocomplete + auth?: AuthConfig; + + // Pagination configuration + pagination?: PaginationConfig; + + // Retry configuration + retry?: RetryConfig; + + // Hooks for extensibility + hooks?: HttpHooks; + + // Additional options + additionalHeaders?: Record; + + // Query parameters + queryParams?: Record; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Default retry configuration + */ +const DEFAULT_RETRY_CONFIG: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffMultiplier: 2, + retryableStatusCodes: [408, 429, 500, 502, 503, 504], + retryOnNetworkError: true, + onRetry: () => {}, +}; + +/** + * Default request hook implementation using node-fetch + */ +const defaultMakeRequest = async (params: HttpRequestParams): Promise => { + return NodeFetch.default(params.url, { + body: params.body, + method: params.method, + headers: params.headers + }) as unknown as HttpResponse; +}; + +/** + * Apply authentication to headers and URL based on auth config + */ +function applyAuth( + auth: AuthConfig | undefined, + headers: Record, + url: string +): { headers: Record; url: string } { + if (!auth) return { headers, url }; + + switch (auth.type) { + case 'bearer': + headers['Authorization'] = \`Bearer \${auth.token}\`; + break; + + case 'basic': { + const credentials = Buffer.from(\`\${auth.username}:\${auth.password}\`).toString('base64'); + headers['Authorization'] = \`Basic \${credentials}\`; + break; + } + + case 'apiKey': { + const keyName = auth.name ?? API_KEY_DEFAULTS.name; + const keyIn = auth.in ?? API_KEY_DEFAULTS.in; + + if (keyIn === 'header') { + headers[keyName] = auth.key; + } 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; + } + + case 'oauth2': { + // If we have an access token, use it directly + // Token flows (client_credentials, password) are handled separately + if (auth.accessToken) { + headers['Authorization'] = \`Bearer \${auth.accessToken}\`; + } + break; + } + } + + return { headers, url }; +} + +/** + * Apply pagination parameters to URL and/or headers based on configuration + */ +function applyPagination( + pagination: PaginationConfig | undefined, + url: string, + headers: Record +): { url: string; headers: Record } { + if (!pagination) return { url, headers }; + + const location = pagination.in ?? 'query'; + const isHeader = location === 'header'; + + // Helper to get default param names based on location + const getDefaultName = (queryName: string, headerName: string) => + isHeader ? headerName : queryName; + + const queryParams = new URLSearchParams(); + const headerParams: Record = {}; + + const addParam = (name: string, value: string) => { + if (isHeader) { + headerParams[name] = value; + } else { + queryParams.append(name, value); + } + }; + + switch (pagination.type) { + case 'offset': + addParam( + pagination.offsetParam ?? getDefaultName('offset', 'X-Offset'), + String(pagination.offset) + ); + addParam( + pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), + String(pagination.limit) + ); + break; + + case 'cursor': + if (pagination.cursor) { + addParam( + pagination.cursorParam ?? getDefaultName('cursor', 'X-Cursor'), + pagination.cursor + ); + } + if (pagination.limit !== undefined) { + addParam( + pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), + String(pagination.limit) + ); + } + break; + + case 'page': + addParam( + pagination.pageParam ?? getDefaultName('page', 'X-Page'), + String(pagination.page) + ); + addParam( + pagination.pageSizeParam ?? getDefaultName('pageSize', 'X-Page-Size'), + String(pagination.pageSize) + ); + break; + + case 'range': { + // Range pagination is always header-based (RFC 7233 style) + const unit = pagination.unit ?? 'items'; + const headerName = pagination.rangeHeader ?? 'Range'; + headerParams[headerName] = \`\${unit}=\${pagination.start}-\${pagination.end}\`; + break; + } + } + + // Apply query params to URL + const queryString = queryParams.toString(); + if (queryString) { + const separator = url.includes('?') ? '&' : '?'; + url = \`\${url}\${separator}\${queryString}\`; + } + + // Merge header params + const updatedHeaders = { ...headers, ...headerParams }; + + return { url, headers: updatedHeaders }; +} + +/** + * Apply query parameters to URL + */ +function applyQueryParams(queryParams: Record | undefined, url: string): string { + if (!queryParams) return url; + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined) { + params.append(key, String(value)); + } + } + + const paramString = params.toString(); + if (!paramString) return url; + + const separator = url.includes('?') ? '&' : '?'; + return \`\${url}\${separator}\${paramString}\`; +} + +/** + * Sleep for a specified number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Calculate delay for exponential backoff + */ +function calculateBackoffDelay( + attempt: number, + config: Required +): number { + const delay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt - 1); + return Math.min(delay, config.maxDelayMs); +} + +/** + * Determine if a request should be retried based on error/response + */ +function shouldRetry( + error: Error | null, + response: HttpResponse | null, + config: Required, + attempt: number +): boolean { + if (attempt >= config.maxRetries) return false; + + if (error && config.retryOnNetworkError) return true; + + if (response && config.retryableStatusCodes.includes(response.status)) return true; + + return false; +} + +/** + * Execute request with retry logic + */ +async function executeWithRetry( + params: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; + let lastError: Error | null = null; + let lastResponse: HttpResponse | null = null; + + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = calculateBackoffDelay(attempt, config); + config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + await sleep(delay); + } + + const response = await makeRequest(params); + + // Check if we should retry this response + if (!shouldRetry(null, response, config, attempt + 1)) { + return response; + } + + lastResponse = response; + lastError = new Error(\`HTTP Error: \${response.status} \${response.statusText}\`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (!shouldRetry(lastError, null, config, attempt + 1)) { + throw lastError; + } + } + } + + // All retries exhausted + if (lastResponse) { + return lastResponse; + } + throw lastError ?? new Error('Request failed after retries'); +} + +/** + * Handle HTTP error status codes with standardized messages + */ +function handleHttpError(status: number, statusText: string): never { + switch (status) { + case 401: + throw new Error('Unauthorized'); + case 403: + throw new Error('Forbidden'); + case 404: + throw new Error('Not Found'); + case 500: + throw new Error('Internal Server Error'); + default: + throw new Error(\`HTTP Error: \${status} \${statusText}\`); + } +} + +/** + * Extract headers from response into a plain object + */ +function extractHeaders(response: HttpResponse): Record { + const headers: Record = {}; + + if (response.headers) { + if (typeof (response.headers as any).forEach === 'function') { + // Headers object (fetch API) + (response.headers as Headers).forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + // Plain object + for (const [key, value] of Object.entries(response.headers)) { + headers[key.toLowerCase()] = value; + } + } + } + + return headers; +} + +/** + * Extract pagination info from response headers + */ +function extractPaginationInfo( + headers: Record, + currentPagination?: PaginationConfig +): PaginationInfo | undefined { + const info: PaginationInfo = {}; + let hasPaginationInfo = false; + + // Common total count headers + const totalCount = headers['x-total-count'] || headers['x-total'] || headers['total-count']; + if (totalCount) { + info.totalCount = parseInt(totalCount, 10); + hasPaginationInfo = true; + } + + // Total pages + const totalPages = headers['x-total-pages'] || headers['x-page-count']; + if (totalPages) { + info.totalPages = parseInt(totalPages, 10); + hasPaginationInfo = true; + } + + // Next cursor + const nextCursor = headers['x-next-cursor'] || headers['x-cursor-next']; + if (nextCursor) { + info.nextCursor = nextCursor; + info.hasMore = true; + hasPaginationInfo = true; + } + + // Previous cursor + const prevCursor = headers['x-prev-cursor'] || headers['x-cursor-prev']; + if (prevCursor) { + info.prevCursor = prevCursor; + hasPaginationInfo = true; + } + + // Has more indicator + const hasMore = headers['x-has-more'] || headers['x-has-next']; + if (hasMore) { + info.hasMore = hasMore.toLowerCase() === 'true' || hasMore === '1'; + hasPaginationInfo = true; + } + + // Parse Link header (RFC 5988) + const linkHeader = headers['link']; + if (linkHeader) { + const links = parseLinkHeader(linkHeader); + if (links.next) { + info.hasMore = true; + hasPaginationInfo = true; + } + } + + // Include current pagination state + if (currentPagination) { + switch (currentPagination.type) { + case 'offset': + info.currentOffset = currentPagination.offset; + info.limit = currentPagination.limit; + break; + case 'cursor': + info.limit = currentPagination.limit; + break; + case 'page': + info.currentOffset = (currentPagination.page - 1) * currentPagination.pageSize; + info.limit = currentPagination.pageSize; + break; + case 'range': + info.currentOffset = currentPagination.start; + info.limit = currentPagination.end - currentPagination.start + 1; + break; + } + hasPaginationInfo = true; + } + + // Calculate hasMore based on total count + if (info.hasMore === undefined && info.totalCount !== undefined && + info.currentOffset !== undefined && info.limit !== undefined) { + info.hasMore = info.currentOffset + info.limit < info.totalCount; + } + + return hasPaginationInfo ? info : undefined; +} + +/** + * Parse RFC 5988 Link header + */ +function parseLinkHeader(header: string): Record { + const links: Record = {}; + const parts = header.split(','); + + for (const part of parts) { + const match = part.match(/<([^>]+)>;\\s*rel="?([^";\\s]+)"?/); + if (match) { + links[match[2]] = match[1]; + } + } + + return links; +} + +/** + * Create pagination helper functions for the response + */ +function createPaginationHelpers( + currentConfig: TContext, + paginationInfo: PaginationInfo | undefined, + requestFn: (config: TContext) => Promise> +): Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> { + const helpers: Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> = {}; + + if (!currentConfig.pagination) { + return helpers; + } + + const pagination = currentConfig.pagination; + + helpers.hasNextPage = () => { + if (paginationInfo?.hasMore !== undefined) return paginationInfo.hasMore; + if (paginationInfo?.nextCursor) return true; + if (paginationInfo?.totalCount !== undefined && + paginationInfo.currentOffset !== undefined && + paginationInfo.limit !== undefined) { + return paginationInfo.currentOffset + paginationInfo.limit < paginationInfo.totalCount; + } + return false; + }; + + helpers.hasPrevPage = () => { + if (paginationInfo?.prevCursor) return true; + if (paginationInfo?.currentOffset !== undefined) { + return paginationInfo.currentOffset > 0; + } + return false; + }; + + helpers.getNextPage = async () => { + let nextPagination: PaginationConfig; + + switch (pagination.type) { + case 'offset': + nextPagination = { ...pagination, offset: pagination.offset + pagination.limit }; + break; + case 'cursor': + if (!paginationInfo?.nextCursor) throw new Error('No next cursor available'); + nextPagination = { ...pagination, cursor: paginationInfo.nextCursor }; + break; + case 'page': + nextPagination = { ...pagination, page: pagination.page + 1 }; + break; + case 'range': + const rangeSize = pagination.end - pagination.start + 1; + nextPagination = { ...pagination, start: pagination.end + 1, end: pagination.end + rangeSize }; + break; + default: + throw new Error('Unsupported pagination type'); + } + + return requestFn({ ...currentConfig, pagination: nextPagination }); + }; + + helpers.getPrevPage = async () => { + let prevPagination: PaginationConfig; + + switch (pagination.type) { + case 'offset': + prevPagination = { ...pagination, offset: Math.max(0, pagination.offset - pagination.limit) }; + break; + case 'cursor': + if (!paginationInfo?.prevCursor) throw new Error('No previous cursor available'); + prevPagination = { ...pagination, cursor: paginationInfo.prevCursor }; + break; + case 'page': + prevPagination = { ...pagination, page: Math.max(1, pagination.page - 1) }; + break; + case 'range': + const size = pagination.end - pagination.start + 1; + const newStart = Math.max(0, pagination.start - size); + prevPagination = { ...pagination, start: newStart, end: newStart + size - 1 }; + break; + default: + throw new Error('Unsupported pagination type'); + } + + return requestFn({ ...currentConfig, pagination: prevPagination }); + }; + + return helpers; +} + +/** + * Builds a URL with path parameters replaced + * @param server - Base server URL + * @param pathTemplate - Path template with {param} placeholders + * @param parameters - Parameter object with getChannelWithParameters method + */ +function buildUrlWithParameters string }>( + server: string, + pathTemplate: string, + parameters: T +): string { + const path = parameters.getChannelWithParameters(pathTemplate); + return \`\${server}\${path}\`; +} + +/** + * Extracts headers from a typed headers object and merges with additional headers + */ +function applyTypedHeaders( + typedHeaders: { marshal: () => string } | undefined, + additionalHeaders: Record | undefined +): Record { + const headers: Record = { + 'Content-Type': 'application/json', + ...additionalHeaders + }; + + if (typedHeaders) { + // Parse the marshalled headers and merge them + const marshalledHeaders = JSON.parse(typedHeaders.marshal()); + for (const [key, value] of Object.entries(marshalledHeaders)) { + headers[key] = value as string; + } + } + + return headers; +} +${requirements.oauth2 ? renderOAuth2Helpers() : renderOAuth2Stubs()} +// ============================================================================ +// Generated HTTP Client Functions +// ============================================================================`; +} diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts deleted file mode 100644 index 9b49f3b1..00000000 --- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts +++ /dev/null @@ -1,1663 +0,0 @@ -import {HttpRenderType} from '../../../../../types'; -import {pascalCase} from '../../../utils'; -import { - ChannelFunctionTypes, - RenderHttpParameters, - SecuritySchemeOptions -} from '../../types'; - -// Re-export for use by other modules -export {SecuritySchemeOptions}; - -/** - * 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) - .replace(/\*\//g, '*\\/'); // Escape */ to prevent JSDoc comment injection -} - -/** - * Determines which auth types are needed based on security schemes. - */ -interface AuthTypeRequirements { - bearer: boolean; - basic: boolean; - apiKey: boolean; - oauth2: boolean; - apiKeySchemes: SecuritySchemeOptions[]; - oauth2Schemes: SecuritySchemeOptions[]; -} - -/** - * Analyzes security schemes to determine which auth types are needed. - */ -function analyzeSecuritySchemes( - schemes: SecuritySchemeOptions[] | undefined -): AuthTypeRequirements { - // undefined or empty array = backward compatibility mode, generate all types - // This allows users to manually configure auth even if no schemes are defined - 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: SecuritySchemeOptions[]): { - 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: SecuritySchemeOptions[] -): 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) - .map((scope) => escapeStringForCodeGen(scope)) - .join(', '); - const suffix = scopes.size > 3 ? '...' : ''; - return ` Available: ${scopeList}${suffix}`; -} - -/** - * Extracts documentation comments from a single OAuth2 scheme. - */ -function extractSchemeComments( - scheme: SecuritySchemeOptions, - 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: SecuritySchemeOptions[] -): 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: SecuritySchemeOptions[] -): 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: SecuritySchemeOptions[] | 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 { - return ` -// 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; }`; -} - -/** - * Generates OAuth2-specific helper functions. - * Only included when OAuth2 auth is needed. - */ -function renderOAuth2Helpers(): string { - return ` -/** - * 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); -}`; -} - -/** - * 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( - securitySchemes?: SecuritySchemeOptions[] -): string { - const requirements = analyzeSecuritySchemes(securitySchemes); - const securityTypes = renderSecurityTypes(securitySchemes, requirements); - - return `// ============================================================================ -// Common Types - Shared across all HTTP client functions -// ============================================================================ - -/** - * Standard HTTP response interface that wraps fetch-like responses - */ -export interface HttpResponse { - ok: boolean; - status: number; - statusText: string; - headers?: Headers | Record; - json: () => Record | Promise>; -} - -/** - * Pagination info extracted from response - */ -export interface PaginationInfo { - /** Total number of items (if available from headers like X-Total-Count) */ - totalCount?: number; - /** Total number of pages (if available) */ - totalPages?: number; - /** Current page/offset */ - currentOffset?: number; - /** Items per page */ - limit?: number; - /** Next cursor (for cursor-based pagination) */ - nextCursor?: string; - /** Previous cursor */ - prevCursor?: string; - /** Whether there are more items */ - hasMore?: boolean; -} - -/** - * Rich response wrapper returned by HTTP client functions - */ -export interface HttpClientResponse { - /** The deserialized response payload */ - data: T; - /** HTTP status code */ - status: number; - /** HTTP status text */ - statusText: string; - /** Response headers */ - headers: Record; - /** Raw JSON response before deserialization */ - rawData: Record; - /** Pagination info extracted from response (if applicable) */ - pagination?: PaginationInfo; - /** Fetch the next page (if pagination is configured and more data exists) */ - getNextPage?: () => Promise>; - /** Fetch the previous page (if pagination is configured) */ - getPrevPage?: () => Promise>; - /** Check if there's a next page */ - hasNextPage?: () => boolean; - /** Check if there's a previous page */ - hasPrevPage?: () => boolean; -} - -/** - * HTTP request parameters passed to the request hook - */ -export interface HttpRequestParams { - url: string; - headers?: Record; - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; - credentials?: RequestCredentials; - body?: any; -} - -/** - * Token response structure for OAuth2 flows - */ -export interface TokenResponse { - accessToken: string; - refreshToken?: string; - expiresIn?: number; -} - -${securityTypes} - -/** - * Feature flags indicating which auth types are available. - * Used internally to conditionally call auth-specific helpers. - */ -const AUTH_FEATURES = { - oauth2: ${requirements.oauth2} -} 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: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).name)}', - in: '${escapeStringForCodeGen(getApiKeyDefaults(requirements.apiKeySchemes).in)}' as 'header' | 'query' | 'cookie' -} as const; - -// ============================================================================ -// Pagination Types -// ============================================================================ - -/** - * Where to place pagination parameters - */ -export type PaginationLocation = 'query' | 'header'; - -/** - * Offset-based pagination configuration - */ -export interface OffsetPagination { - type: 'offset'; - in?: PaginationLocation; // Where to place params (default: 'query') - offset: number; - limit: number; - offsetParam?: string; // Param name for offset (default: 'offset' for query, 'X-Offset' for header) - limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) -} - -/** - * Cursor-based pagination configuration - */ -export interface CursorPagination { - type: 'cursor'; - in?: PaginationLocation; // Where to place params (default: 'query') - cursor?: string; - limit?: number; - cursorParam?: string; // Param name for cursor (default: 'cursor' for query, 'X-Cursor' for header) - limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) -} - -/** - * Page-based pagination configuration - */ -export interface PagePagination { - type: 'page'; - in?: PaginationLocation; // Where to place params (default: 'query') - page: number; - pageSize: number; - pageParam?: string; // Param name for page (default: 'page' for query, 'X-Page' for header) - pageSizeParam?: string; // Param name for page size (default: 'pageSize' for query, 'X-Page-Size' for header) -} - -/** - * Range-based pagination (typically used with headers) - * Follows RFC 7233 style: Range: items=0-24 - */ -export interface RangePagination { - type: 'range'; - in?: 'header'; // Range pagination is typically header-only - start: number; - end: number; - unit?: string; // Range unit (default: 'items') - rangeHeader?: string; // Header name (default: 'Range') -} - -/** - * Union type for all pagination methods - */ -export type PaginationConfig = OffsetPagination | CursorPagination | PagePagination | RangePagination; - -// ============================================================================ -// Retry Configuration -// ============================================================================ - -/** - * Retry policy configuration for failed requests - */ -export interface RetryConfig { - maxRetries?: number; // Maximum number of retry attempts (default: 3) - initialDelayMs?: number; // Initial delay before first retry (default: 1000) - maxDelayMs?: number; // Maximum delay between retries (default: 30000) - backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) - retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) - retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry -} - -// ============================================================================ -// Hooks Configuration - Extensible callback system -// ============================================================================ - -/** - * Hooks for customizing HTTP client behavior - */ -export interface HttpHooks { - /** - * Called before each request to transform/modify the request parameters - * Return modified params or undefined to use original - */ - beforeRequest?: (params: HttpRequestParams) => HttpRequestParams | Promise; - - /** - * The actual request implementation - allows swapping fetch for axios, etc. - * Default: uses node-fetch - */ - makeRequest?: (params: HttpRequestParams) => Promise; - - /** - * Called after each response for logging, metrics, etc. - * Can transform the response before it's processed - */ - afterResponse?: (response: HttpResponse, params: HttpRequestParams) => HttpResponse | Promise; - - /** - * Called on request error for logging, error transformation, etc. - */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; -} - -// ============================================================================ -// Common Request Context -// ============================================================================ - -/** - * Base context shared by all HTTP client functions - */ -export interface HttpClientContext { - server?: string; - path?: string; - - // Authentication - grouped for better autocomplete - auth?: AuthConfig; - - // Pagination configuration - pagination?: PaginationConfig; - - // Retry configuration - retry?: RetryConfig; - - // Hooks for extensibility - hooks?: HttpHooks; - - // Additional options - additionalHeaders?: Record; - - // Query parameters - queryParams?: Record; -} - -// ============================================================================ -// Helper Functions - Shared logic extracted for reuse -// ============================================================================ - -/** - * Default retry configuration - */ -const DEFAULT_RETRY_CONFIG: Required = { - maxRetries: 3, - initialDelayMs: 1000, - maxDelayMs: 30000, - backoffMultiplier: 2, - retryableStatusCodes: [408, 429, 500, 502, 503, 504], - retryOnNetworkError: true, - onRetry: () => {}, -}; - -/** - * Default request hook implementation using node-fetch - */ -const defaultMakeRequest = async (params: HttpRequestParams): Promise => { - return NodeFetch.default(params.url, { - body: params.body, - method: params.method, - headers: params.headers - }) as unknown as HttpResponse; -}; - -/** - * Apply authentication to headers and URL based on auth config - */ -function applyAuth( - auth: AuthConfig | undefined, - headers: Record, - url: string -): { headers: Record; url: string } { - if (!auth) return { headers, url }; - - switch (auth.type) { - case 'bearer': - headers['Authorization'] = \`Bearer \${auth.token}\`; - break; - - case 'basic': { - const credentials = Buffer.from(\`\${auth.username}:\${auth.password}\`).toString('base64'); - headers['Authorization'] = \`Basic \${credentials}\`; - break; - } - - case 'apiKey': { - const keyName = auth.name ?? API_KEY_DEFAULTS.name; - const keyIn = auth.in ?? API_KEY_DEFAULTS.in; - - if (keyIn === 'header') { - headers[keyName] = auth.key; - } 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; - } - - case 'oauth2': { - // If we have an access token, use it directly - // Token flows (client_credentials, password) are handled separately - if (auth.accessToken) { - headers['Authorization'] = \`Bearer \${auth.accessToken}\`; - } - break; - } - } - - return { headers, url }; -} - -/** - * Apply pagination parameters to URL and/or headers based on configuration - */ -function applyPagination( - pagination: PaginationConfig | undefined, - url: string, - headers: Record -): { url: string; headers: Record } { - if (!pagination) return { url, headers }; - - const location = pagination.in ?? 'query'; - const isHeader = location === 'header'; - - // Helper to get default param names based on location - const getDefaultName = (queryName: string, headerName: string) => - isHeader ? headerName : queryName; - - const queryParams = new URLSearchParams(); - const headerParams: Record = {}; - - const addParam = (name: string, value: string) => { - if (isHeader) { - headerParams[name] = value; - } else { - queryParams.append(name, value); - } - }; - - switch (pagination.type) { - case 'offset': - addParam( - pagination.offsetParam ?? getDefaultName('offset', 'X-Offset'), - String(pagination.offset) - ); - addParam( - pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), - String(pagination.limit) - ); - break; - - case 'cursor': - if (pagination.cursor) { - addParam( - pagination.cursorParam ?? getDefaultName('cursor', 'X-Cursor'), - pagination.cursor - ); - } - if (pagination.limit !== undefined) { - addParam( - pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), - String(pagination.limit) - ); - } - break; - - case 'page': - addParam( - pagination.pageParam ?? getDefaultName('page', 'X-Page'), - String(pagination.page) - ); - addParam( - pagination.pageSizeParam ?? getDefaultName('pageSize', 'X-Page-Size'), - String(pagination.pageSize) - ); - break; - - case 'range': { - // Range pagination is always header-based (RFC 7233 style) - const unit = pagination.unit ?? 'items'; - const headerName = pagination.rangeHeader ?? 'Range'; - headerParams[headerName] = \`\${unit}=\${pagination.start}-\${pagination.end}\`; - break; - } - } - - // Apply query params to URL - const queryString = queryParams.toString(); - if (queryString) { - const separator = url.includes('?') ? '&' : '?'; - url = \`\${url}\${separator}\${queryString}\`; - } - - // Merge header params - const updatedHeaders = { ...headers, ...headerParams }; - - return { url, headers: updatedHeaders }; -} - -/** - * Apply query parameters to URL - */ -function applyQueryParams(queryParams: Record | undefined, url: string): string { - if (!queryParams) return url; - - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(queryParams)) { - if (value !== undefined) { - params.append(key, String(value)); - } - } - - const paramString = params.toString(); - if (!paramString) return url; - - const separator = url.includes('?') ? '&' : '?'; - return \`\${url}\${separator}\${paramString}\`; -} - -/** - * Sleep for a specified number of milliseconds - */ -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Calculate delay for exponential backoff - */ -function calculateBackoffDelay( - attempt: number, - config: Required -): number { - const delay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt - 1); - return Math.min(delay, config.maxDelayMs); -} - -/** - * Determine if a request should be retried based on error/response - */ -function shouldRetry( - error: Error | null, - response: HttpResponse | null, - config: Required, - attempt: number -): boolean { - if (attempt >= config.maxRetries) return false; - - if (error && config.retryOnNetworkError) return true; - - if (response && config.retryableStatusCodes.includes(response.status)) return true; - - return false; -} - -/** - * Execute request with retry logic - */ -async function executeWithRetry( - params: HttpRequestParams, - makeRequest: (params: HttpRequestParams) => Promise, - retryConfig?: RetryConfig -): Promise { - const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; - let lastResponse: HttpResponse | null = null; - - for (let attempt = 0; attempt <= config.maxRetries; attempt++) { - try { - if (attempt > 0) { - const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); - await sleep(delay); - } - - const response = await makeRequest(params); - - // Check if we should retry this response - if (!shouldRetry(null, response, config, attempt + 1)) { - return response; - } - - lastResponse = response; - lastError = new Error(\`HTTP Error: \${response.status} \${response.statusText}\`); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - if (!shouldRetry(lastError, null, config, attempt + 1)) { - throw lastError; - } - } - } - - // All retries exhausted - if (lastResponse) { - return lastResponse; - } - throw lastError ?? new Error('Request failed after retries'); -} - -/** - * Handle HTTP error status codes with standardized messages - */ -function handleHttpError(status: number, statusText: string): never { - switch (status) { - case 401: - throw new Error('Unauthorized'); - case 403: - throw new Error('Forbidden'); - case 404: - throw new Error('Not Found'); - case 500: - throw new Error('Internal Server Error'); - default: - throw new Error(\`HTTP Error: \${status} \${statusText}\`); - } -} - -/** - * Extract headers from response into a plain object - */ -function extractHeaders(response: HttpResponse): Record { - const headers: Record = {}; - - if (response.headers) { - if (typeof (response.headers as any).forEach === 'function') { - // Headers object (fetch API) - (response.headers as Headers).forEach((value, key) => { - headers[key.toLowerCase()] = value; - }); - } else { - // Plain object - for (const [key, value] of Object.entries(response.headers)) { - headers[key.toLowerCase()] = value; - } - } - } - - return headers; -} - -/** - * Extract pagination info from response headers - */ -function extractPaginationInfo( - headers: Record, - currentPagination?: PaginationConfig -): PaginationInfo | undefined { - const info: PaginationInfo = {}; - let hasPaginationInfo = false; - - // Common total count headers - const totalCount = headers['x-total-count'] || headers['x-total'] || headers['total-count']; - if (totalCount) { - info.totalCount = parseInt(totalCount, 10); - hasPaginationInfo = true; - } - - // Total pages - const totalPages = headers['x-total-pages'] || headers['x-page-count']; - if (totalPages) { - info.totalPages = parseInt(totalPages, 10); - hasPaginationInfo = true; - } - - // Next cursor - const nextCursor = headers['x-next-cursor'] || headers['x-cursor-next']; - if (nextCursor) { - info.nextCursor = nextCursor; - info.hasMore = true; - hasPaginationInfo = true; - } - - // Previous cursor - const prevCursor = headers['x-prev-cursor'] || headers['x-cursor-prev']; - if (prevCursor) { - info.prevCursor = prevCursor; - hasPaginationInfo = true; - } - - // Has more indicator - const hasMore = headers['x-has-more'] || headers['x-has-next']; - if (hasMore) { - info.hasMore = hasMore.toLowerCase() === 'true' || hasMore === '1'; - hasPaginationInfo = true; - } - - // Parse Link header (RFC 5988) - const linkHeader = headers['link']; - if (linkHeader) { - const links = parseLinkHeader(linkHeader); - if (links.next) { - info.hasMore = true; - hasPaginationInfo = true; - } - } - - // Include current pagination state - if (currentPagination) { - switch (currentPagination.type) { - case 'offset': - info.currentOffset = currentPagination.offset; - info.limit = currentPagination.limit; - break; - case 'cursor': - info.limit = currentPagination.limit; - break; - case 'page': - info.currentOffset = (currentPagination.page - 1) * currentPagination.pageSize; - info.limit = currentPagination.pageSize; - break; - case 'range': - info.currentOffset = currentPagination.start; - info.limit = currentPagination.end - currentPagination.start + 1; - break; - } - hasPaginationInfo = true; - } - - // Calculate hasMore based on total count - if (info.hasMore === undefined && info.totalCount !== undefined && - info.currentOffset !== undefined && info.limit !== undefined) { - info.hasMore = info.currentOffset + info.limit < info.totalCount; - } - - return hasPaginationInfo ? info : undefined; -} - -/** - * Parse RFC 5988 Link header - */ -function parseLinkHeader(header: string): Record { - const links: Record = {}; - const parts = header.split(','); - - for (const part of parts) { - const match = part.match(/<([^>]+)>;\\s*rel="?([^";\\s]+)"?/); - if (match) { - links[match[2]] = match[1]; - } - } - - return links; -} - -/** - * Create pagination helper functions for the response - */ -function createPaginationHelpers( - currentConfig: TContext, - paginationInfo: PaginationInfo | undefined, - requestFn: (config: TContext) => Promise> -): Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> { - const helpers: Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> = {}; - - if (!currentConfig.pagination) { - return helpers; - } - - const pagination = currentConfig.pagination; - - helpers.hasNextPage = () => { - if (paginationInfo?.hasMore !== undefined) return paginationInfo.hasMore; - if (paginationInfo?.nextCursor) return true; - if (paginationInfo?.totalCount !== undefined && - paginationInfo.currentOffset !== undefined && - paginationInfo.limit !== undefined) { - return paginationInfo.currentOffset + paginationInfo.limit < paginationInfo.totalCount; - } - return false; - }; - - helpers.hasPrevPage = () => { - if (paginationInfo?.prevCursor) return true; - if (paginationInfo?.currentOffset !== undefined) { - return paginationInfo.currentOffset > 0; - } - return false; - }; - - helpers.getNextPage = async () => { - let nextPagination: PaginationConfig; - - switch (pagination.type) { - case 'offset': - nextPagination = { ...pagination, offset: pagination.offset + pagination.limit }; - break; - case 'cursor': - if (!paginationInfo?.nextCursor) throw new Error('No next cursor available'); - nextPagination = { ...pagination, cursor: paginationInfo.nextCursor }; - break; - case 'page': - nextPagination = { ...pagination, page: pagination.page + 1 }; - break; - case 'range': - const rangeSize = pagination.end - pagination.start + 1; - nextPagination = { ...pagination, start: pagination.end + 1, end: pagination.end + rangeSize }; - break; - default: - throw new Error('Unsupported pagination type'); - } - - return requestFn({ ...currentConfig, pagination: nextPagination }); - }; - - helpers.getPrevPage = async () => { - let prevPagination: PaginationConfig; - - switch (pagination.type) { - case 'offset': - prevPagination = { ...pagination, offset: Math.max(0, pagination.offset - pagination.limit) }; - break; - case 'cursor': - if (!paginationInfo?.prevCursor) throw new Error('No previous cursor available'); - prevPagination = { ...pagination, cursor: paginationInfo.prevCursor }; - break; - case 'page': - prevPagination = { ...pagination, page: Math.max(1, pagination.page - 1) }; - break; - case 'range': - const size = pagination.end - pagination.start + 1; - const newStart = Math.max(0, pagination.start - size); - prevPagination = { ...pagination, start: newStart, end: newStart + size - 1 }; - break; - default: - throw new Error('Unsupported pagination type'); - } - - return requestFn({ ...currentConfig, pagination: prevPagination }); - }; - - return helpers; -} - -/** - * Builds a URL with path parameters replaced - * @param server - Base server URL - * @param pathTemplate - Path template with {param} placeholders - * @param parameters - Parameter object with getChannelWithParameters method - */ -function buildUrlWithParameters string }>( - server: string, - pathTemplate: string, - parameters: T -): string { - const path = parameters.getChannelWithParameters(pathTemplate); - return \`\${server}\${path}\`; -} - -/** - * Extracts headers from a typed headers object and merges with additional headers - */ -function applyTypedHeaders( - typedHeaders: { marshal: () => string } | undefined, - additionalHeaders: Record | undefined -): Record { - const headers: Record = { - 'Content-Type': 'application/json', - ...additionalHeaders - }; - - if (typedHeaders) { - // Parse the marshalled headers and merge them - const marshalledHeaders = JSON.parse(typedHeaders.marshal()); - for (const [key, value] of Object.entries(marshalledHeaders)) { - headers[key] = value as string; - } - } - - return headers; -} -${requirements.oauth2 ? renderOAuth2Helpers() : renderOAuth2Stubs()} -// ============================================================================ -// Generated HTTP Client Functions -// ============================================================================`; -} - -export function renderHttpFetchClient({ - requestTopic, - requestMessageType, - requestMessageModule, - replyMessageType, - replyMessageModule, - channelParameters, - method, - servers = [], - subName = pascalCase(requestTopic), - functionName = `${method.toLowerCase()}${subName}`, - includesStatusCodes = false -}: RenderHttpParameters): HttpRenderType { - const messageType = requestMessageModule - ? `${requestMessageModule}.${requestMessageType}` - : requestMessageType; - const replyType = replyMessageModule - ? `${replyMessageModule}.${replyMessageType}` - : replyMessageType; - - // Generate context interface name - const contextInterfaceName = `${pascalCase(functionName)}Context`; - - // Determine if operation has path parameters - const hasParameters = channelParameters !== undefined; - - // Generate the context interface (extends HttpClientContext) - const contextInterface = generateContextInterface( - contextInterfaceName, - messageType, - hasParameters, - method - ); - - // Generate the function implementation - const functionCode = generateFunctionImplementation({ - functionName, - contextInterfaceName, - replyType, - replyMessageModule, - replyMessageType, - messageType, - requestTopic, - hasParameters, - method, - servers, - includesStatusCodes - }); - - const code = `${contextInterface} - -${functionCode}`; - - return { - messageType, - replyType, - code, - functionName, - dependencies: [ - `import { URLSearchParams, URL } from 'url';`, - `import * as NodeFetch from 'node-fetch';` - ], - functionType: ChannelFunctionTypes.HTTP_CLIENT - }; -} - -/** - * Generate the context interface for an HTTP operation - */ -function generateContextInterface( - interfaceName: string, - messageType: string | undefined, - hasParameters: boolean, - method: string -): string { - const fields: string[] = []; - - // Add payload field for methods that have a body - if (messageType && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) { - fields.push(` payload: ${messageType};`); - } - - // Add parameters field if operation has path parameters - if (hasParameters) { - fields.push( - ` parameters: { getChannelWithParameters: (path: string) => string };` - ); - } - - // Add requestHeaders field (optional) for operations that support typed headers - // This is always optional since headers can also be passed via additionalHeaders - fields.push(` requestHeaders?: { marshal: () => string };`); - - const fieldsStr = fields.length > 0 ? `\n${fields.join('\n')}\n` : ''; - - return `export interface ${interfaceName} extends HttpClientContext {${fieldsStr}}`; -} - -/** - * Generate the function implementation - */ -function generateFunctionImplementation(params: { - functionName: string; - contextInterfaceName: string; - replyType: string; - replyMessageModule: string | undefined; - replyMessageType: string; - messageType: string | undefined; - requestTopic: string; - hasParameters: boolean; - method: string; - servers: string[]; - includesStatusCodes: boolean; -}): string { - const { - functionName, - contextInterfaceName, - replyType, - replyMessageModule, - replyMessageType, - messageType, - requestTopic, - hasParameters, - method, - servers, - includesStatusCodes - } = params; - - const defaultServer = servers[0] ?? "'localhost:3000'"; - const hasBody = - messageType && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()); - - // Generate URL building code - const urlBuildCode = hasParameters - ? `let url = buildUrlWithParameters(config.server, '${requestTopic}', context.parameters);` - : 'let url = `${config.server}${config.path}`;'; - - // Generate headers initialization - const headersInit = `let headers = context.requestHeaders - ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) - : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;`; - - // Generate body preparation - const bodyPrep = hasBody - ? `const body = context.payload?.marshal();` - : `const body = undefined;`; - - // Generate response parsing - // Use unmarshalByStatusCode if the payload is a union type with status code support - let responseParseCode: string; - if (replyMessageModule) { - responseParseCode = includesStatusCodes - ? `const responseData = ${replyMessageModule}.unmarshalByStatusCode(rawData, response.status);` - : `const responseData = ${replyMessageModule}.unmarshal(rawData);`; - } else { - responseParseCode = `const responseData = ${replyMessageType}.unmarshal(rawData);`; - } - - // Generate default context for optional context parameter - const contextDefault = !hasBody && !hasParameters ? ' = {}' : ''; - - return `async function ${functionName}(context: ${contextInterfaceName}${contextDefault}): Promise> { - // Apply defaults - const config = { - path: '${requestTopic}', - server: ${defaultServer}, - ...context, - }; - - // Validate OAuth2 config if present - if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { - validateOAuth2Config(config.auth); - } - - // Build headers - ${headersInit} - - // Build URL - ${urlBuildCode} - 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); - headers = authResult.headers; - url = authResult.url; - - // Prepare body - ${bodyPrep} - - // Determine request function - const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; - - // Build request params - let requestParams: HttpRequestParams = { - url, - method: '${method}', - headers, - body - }; - - // Apply beforeRequest hook - if (config.hooks?.beforeRequest) { - requestParams = await config.hooks.beforeRequest(requestParams); - } - - try { - // Execute request with retry logic - let response = await executeWithRetry(requestParams, makeRequest, config.retry); - - // Apply afterResponse hook - if (config.hooks?.afterResponse) { - response = await config.hooks.afterResponse(response, requestParams); - } - - // Handle OAuth2 token flows that require getting a token first - 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; - } - } - - // Handle 401 with token refresh - if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { - try { - const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); - if (refreshResponse) { - response = refreshResponse; - } - } catch { - throw new Error('Unauthorized'); - } - } - - // Handle error responses - if (!response.ok) { - handleHttpError(response.status, response.statusText); - } - - // Parse response - const rawData = await response.json(); - ${responseParseCode} - - // Extract response metadata - const responseHeaders = extractHeaders(response); - const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); - - // Build response wrapper with pagination helpers - const result: HttpClientResponse<${replyType}> = { - data: responseData, - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - rawData, - pagination: paginationInfo, - ...createPaginationHelpers(config, paginationInfo, ${functionName}), - }; - - return result; - - } catch (error) { - // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { - throw await config.hooks.onError(error, requestParams); - } - throw error; - } -}`; -} diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index a90d5ed7..8a6e9aec 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -17,9 +17,25 @@ import { import {ChannelInterface} from '@asyncapi/parser'; import {HttpRenderType, SingleFunctionRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; -import {renderHttpFetchClient, renderHttpCommonTypes} from './fetch'; +import {renderHttpCommonTypes} from './common-types'; +import {renderHttpFetchClient} from './client'; -export {renderHttpFetchClient, renderHttpCommonTypes}; +// Re-export main functions for backward compatibility +export {renderHttpCommonTypes, renderHttpFetchClient}; + +// Re-export security utilities for external use +export { + analyzeSecuritySchemes, + escapeStringForCodeGen, + getApiKeyDefaults, + renderOAuth2Helpers, + renderOAuth2Stubs, + renderSecurityTypes, + type AuthTypeRequirements +} from './security'; + +// Re-export types +export type {SecuritySchemeOptions} from '../../types'; export async function generatehttpChannels( context: TypeScriptChannelsGeneratorContext, diff --git a/src/codegen/generators/typescript/channels/protocols/http/security.ts b/src/codegen/generators/typescript/channels/protocols/http/security.ts new file mode 100644 index 00000000..a15cc0fe --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/security.ts @@ -0,0 +1,573 @@ +/** + * Security type generation for HTTP client. + * Analyzes OpenAPI security schemes and generates TypeScript auth interfaces. + */ +import {SecuritySchemeOptions} from '../../types'; + +/** + * 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. + */ +export 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) + .replace(/\*\//g, '*\\/'); // Escape */ to prevent JSDoc comment injection +} + +/** + * Determines which auth types are needed based on security schemes. + */ +export interface AuthTypeRequirements { + bearer: boolean; + basic: boolean; + apiKey: boolean; + oauth2: boolean; + apiKeySchemes: SecuritySchemeOptions[]; + oauth2Schemes: SecuritySchemeOptions[]; +} + +/** + * Analyzes security schemes to determine which auth types are needed. + */ +export function analyzeSecuritySchemes( + schemes: SecuritySchemeOptions[] | undefined +): AuthTypeRequirements { + // undefined or empty array = backward compatibility mode, generate all types + // This allows users to manually configure auth even if no schemes are defined + 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. + */ +export function getApiKeyDefaults(apiKeySchemes: SecuritySchemeOptions[]): { + 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: SecuritySchemeOptions[] +): 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) + .map((scope) => escapeStringForCodeGen(scope)) + .join(', '); + const suffix = scopes.size > 3 ? '...' : ''; + return ` Available: ${scopeList}${suffix}`; +} + +/** + * Extracts documentation comments from a single OAuth2 scheme. + */ +function extractSchemeComments( + scheme: SecuritySchemeOptions, + 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: SecuritySchemeOptions[] +): 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: SecuritySchemeOptions[] +): 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. + */ +export function renderSecurityTypes( + schemes: SecuritySchemeOptions[] | undefined, + requirements?: AuthTypeRequirements +): string { + const authRequirements = requirements ?? analyzeSecuritySchemes(schemes); + + const bearerSection = authRequirements.bearer + ? `${renderBearerAuthInterface()}\n\n` + : ''; + + const basicSection = authRequirements.basic + ? `${renderBasicAuthInterface()}\n\n` + : ''; + + const apiKeySection = authRequirements.apiKey + ? `${renderApiKeyAuthInterface(authRequirements.apiKeySchemes)}\n\n` + : ''; + + const oauth2Section = authRequirements.oauth2 + ? `${renderOAuth2AuthInterface(authRequirements.oauth2Schemes)}\n\n` + : ''; + + return `// ============================================================================ +// Security Configuration Types - Grouped for better autocomplete +// ============================================================================ + +${bearerSection}${basicSection}${apiKeySection}${oauth2Section}${renderAuthConfigType(authRequirements)}`; +} + +/** + * 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. + */ +export function renderOAuth2Stubs(): string { + return ` +// 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; }`; +} + +/** + * Generates OAuth2-specific helper functions. + * Only included when OAuth2 auth is needed. + */ +export function renderOAuth2Helpers(): string { + return ` +/** + * 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); +}`; +} diff --git a/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts b/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts index 524d10a3..e4c7e81a 100644 --- a/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts +++ b/test/codegen/generators/typescript/channels/protocols/http/fetch-security.spec.ts @@ -1,7 +1,7 @@ import { renderHttpCommonTypes, SecuritySchemeOptions -} from '../../../../../../../src/codegen/generators/typescript/channels/protocols/http/fetch'; +} from '../../../../../../../src/codegen/generators/typescript/channels/protocols/http'; describe('HTTP Fetch Generator - Security Types', () => { describe('renderHttpCommonTypes with security schemes', () => { From 5d997f3dbada2fea213e98021600d4b555f5da17 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 12 Mar 2026 22:56:22 +0100 Subject: [PATCH 19/19] fix: add newline and carriage return escaping to escapeStringForCodeGen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escape \n and \r characters to prevent syntax errors in generated TypeScript when OpenAPI spec values contain newlines (e.g., via YAML multi-line strings). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../generators/typescript/channels/protocols/http/security.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/codegen/generators/typescript/channels/protocols/http/security.ts b/src/codegen/generators/typescript/channels/protocols/http/security.ts index a15cc0fe..67cd19f9 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/security.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/security.ts @@ -14,6 +14,8 @@ export function escapeStringForCodeGen(value: string | undefined): string { } return value .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/\n/g, '\\n') // Escape newlines + .replace(/\r/g, '\\r') // Escape carriage returns .replace(/'/g, "\\'") // Escape single quotes .replace(/`/g, '\\`') // Escape backticks .replace(/\$/g, '\\$') // Escape dollar signs (prevents ${} template evaluation)