diff --git a/docs/protocols/http_client.md b/docs/protocols/http_client.md
index e0e104b8..ae749a71 100644
--- a/docs/protocols/http_client.md
+++ b/docs/protocols/http_client.md
@@ -4,11 +4,11 @@ sidebar_position: 99
# HTTP(S)
-Both client and server generator is available.
+HTTP client generator creates type-safe functions for making HTTP requests based on your API specification. It supports various authentication methods, pagination, retry logic, and extensibility hooks.
It is currently available through the generators ([channels](../generators/channels.md)):
-All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP `method` binding for operation and `statusCode` for messages.
+All of this is available through [AsyncAPI](../inputs/asyncapi.md). [Requires HTTP `method` binding for operation and `statusCode` for messages](../inputs/asyncapi.md#http-client).
## TypeScript
@@ -16,26 +16,488 @@ All of this is available through [AsyncAPI](../inputs/asyncapi.md). Require HTTP
|---|---|
| Download | ❌ |
| Upload | ❌ |
-| Offset based Pagination | ❌ |
-| Cursor based Pagination | ❌ |
-| Page based Pagination | ❌ |
-| Time based Pagination | ❌ |
-| Keyset based Pagination | ❌ |
-| Retry with backoff | ❌ |
-| OAuth2 Authorization code | ✅ |
-| OAuth2 Implicit | ✅ |
-| OAuth2 password | ✅ |
+| Offset based Pagination | ✅ |
+| Cursor based Pagination | ✅ |
+| Page based Pagination | ✅ |
+| Range based Pagination | ✅ |
+| Retry with backoff | ✅ |
+| OAuth2 Authorization code | ❌ (browser-only) |
+| OAuth2 Implicit | ❌ (browser-only) |
+| OAuth2 Password | ✅ |
| OAuth2 Client Credentials | ✅ |
+| OAuth2 Token Refresh | ✅ |
| Username/password Authentication | ✅ |
| Bearer Authentication | ✅ |
| Basic Authentication | ✅ |
| API Key Authentication | ✅ |
-| XML Based API | ❌ |
-| JSON Based API | ✅ |
+| Request/Response Hooks | ✅ |
+| XML Based API | ❌ |
+| JSON Based API | ✅ |
| POST | ✅ |
| GET | ✅ |
| PATCH | ✅ |
| DELETE | ✅ |
| PUT | ✅ |
| HEAD | ✅ |
-| OPTIONS | ✅ |
\ No newline at end of file
+| OPTIONS | ✅ |
+
+## Channels
+
+Read more about the [channels generator here](../generators/channels.md).
+
+
+
+
+ | Input (AsyncAPI) |
+ Using the code |
+
+
+
+
+ |
+
+```yaml
+asyncapi: 3.0.0
+info:
+ title: User API
+ version: 1.0.0
+channels:
+ ping:
+ address: /ping
+ messages:
+ pingRequest:
+ $ref: '#/components/messages/PingRequest'
+ pongResponse:
+ $ref: '#/components/messages/PongResponse'
+operations:
+ postPing:
+ action: send
+ channel:
+ $ref: '#/channels/ping'
+ bindings:
+ http:
+ method: POST
+ reply:
+ channel:
+ $ref: '#/channels/ping'
+ messages:
+ - $ref: '#/channels/ping/messages/pongResponse'
+components:
+ messages:
+ PingRequest:
+ payload:
+ type: object
+ properties:
+ message:
+ type: string
+ PongResponse:
+ payload:
+ type: object
+ properties:
+ response:
+ type: string
+ bindings:
+ http:
+ statusCode: 200
+```
+ |
+
+
+```ts
+// Location depends on the payload generator configurations
+import { Ping } from './__gen__/payloads/Ping';
+import { Pong } from './__gen__/payloads/Pong';
+// Location depends on the channel generator configurations
+import { Protocols } from './__gen__/channels';
+const { http_client } = Protocols;
+const { postPingPostRequest } = http_client;
+
+// Create a request payload
+const pingMessage = new Ping({ message: 'Hello!' });
+
+// Make a simple request
+const response = await postPingPostRequest({
+ payload: pingMessage,
+ server: 'https://api.example.com'
+});
+
+// Access the response
+console.log(response.data.response); // The deserialized Pong
+console.log(response.status); // 200
+console.log(response.headers); // Response headers
+console.log(response.rawData); // Raw JSON response
+```
+ |
+
+
+
+
+## Authentication
+
+The HTTP client uses a discriminated union for authentication, providing excellent TypeScript autocomplete support.
+
+### Bearer Token
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'bearer',
+ token: 'your-jwt-token'
+ }
+});
+```
+
+### Basic Authentication
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'basic',
+ username: 'user',
+ password: 'pass'
+ }
+});
+```
+
+### API Key
+
+```typescript
+// API Key in header (default)
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'apiKey',
+ key: 'your-api-key',
+ name: 'X-API-Key', // Header name (default: 'X-API-Key')
+ in: 'header' // 'header' or 'query'
+ }
+});
+
+// API Key in query parameter
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'apiKey',
+ key: 'your-api-key',
+ name: 'api_key',
+ in: 'query'
+ }
+});
+```
+
+### OAuth2 Client Credentials
+
+For server-to-server authentication:
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'your-client-id',
+ clientSecret: 'your-client-secret',
+ tokenUrl: 'https://auth.example.com/oauth/token',
+ scopes: ['read', 'write'],
+ onTokenRefresh: (tokens) => {
+ // Called when tokens are obtained/refreshed
+ console.log('New access token:', tokens.accessToken);
+ }
+ }
+});
+```
+
+### OAuth2 Password Flow
+
+For legacy applications requiring username/password:
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'oauth2',
+ flow: 'password',
+ clientId: 'your-client-id',
+ username: 'user@example.com',
+ password: 'user-password',
+ tokenUrl: 'https://auth.example.com/oauth/token',
+ onTokenRefresh: (tokens) => {
+ // Store tokens for future use
+ saveTokens(tokens);
+ }
+ }
+});
+```
+
+### OAuth2 with Pre-obtained Token
+
+For tokens obtained via browser-based flows (implicit, authorization code):
+
+```typescript
+// Token obtained from browser OAuth flow
+const accessToken = getTokenFromBrowserFlow();
+
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ auth: {
+ type: 'oauth2',
+ accessToken: accessToken,
+ refreshToken: refreshToken, // Optional: for auto-refresh on 401
+ tokenUrl: 'https://auth.example.com/oauth/token',
+ clientId: 'your-client-id',
+ onTokenRefresh: (tokens) => {
+ // Update stored tokens
+ updateStoredTokens(tokens);
+ }
+ }
+});
+```
+
+## Pagination
+
+The HTTP client supports multiple pagination strategies. Pagination parameters can be placed in query parameters or headers.
+
+### Offset-based Pagination
+
+```typescript
+const response = await getItemsRequest({
+ server: 'https://api.example.com',
+ pagination: {
+ type: 'offset',
+ offset: 0,
+ limit: 25,
+ in: 'query', // 'query' or 'header'
+ offsetParam: 'offset', // Query param name (default: 'offset')
+ limitParam: 'limit' // Query param name (default: 'limit')
+ }
+});
+
+// Navigate pages
+if (response.hasNextPage?.()) {
+ const nextPage = await response.getNextPage?.();
+}
+```
+
+### Cursor-based Pagination
+
+```typescript
+const response = await getItemsRequest({
+ server: 'https://api.example.com',
+ pagination: {
+ type: 'cursor',
+ cursor: undefined, // First page
+ limit: 25,
+ cursorParam: 'cursor'
+ }
+});
+
+// Get next page using cursor from response
+if (response.pagination?.nextCursor) {
+ const nextPage = await response.getNextPage?.();
+}
+```
+
+### Page-based Pagination
+
+```typescript
+const response = await getItemsRequest({
+ server: 'https://api.example.com',
+ pagination: {
+ type: 'page',
+ page: 1,
+ pageSize: 25,
+ pageParam: 'page',
+ pageSizeParam: 'per_page'
+ }
+});
+```
+
+### Range-based Pagination (RFC 7233)
+
+```typescript
+const response = await getItemsRequest({
+ server: 'https://api.example.com',
+ pagination: {
+ type: 'range',
+ start: 0,
+ end: 24,
+ unit: 'items', // Range unit (default: 'items')
+ rangeHeader: 'Range' // Header name (default: 'Range')
+ }
+});
+// Sends: Range: items=0-24
+```
+
+## Retry with Exponential Backoff
+
+Configure automatic retry for failed requests:
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ retry: {
+ maxRetries: 3, // Maximum retry attempts (default: 3)
+ initialDelayMs: 1000, // Initial delay before first retry (default: 1000)
+ maxDelayMs: 30000, // Maximum delay between retries (default: 30000)
+ backoffMultiplier: 2, // Exponential backoff multiplier (default: 2)
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504], // Status codes to retry
+ retryOnNetworkError: true, // Retry on network failures
+ onRetry: (attempt, delay, error) => {
+ console.log(`Retry attempt ${attempt} after ${delay}ms: ${error.message}`);
+ }
+ }
+});
+```
+
+## Request/Response Hooks
+
+Customize request behavior with hooks:
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ hooks: {
+ // Modify request before sending
+ beforeRequest: async (params) => {
+ console.log('Making request to:', params.url);
+ // Add custom header
+ return {
+ ...params,
+ headers: {
+ ...params.headers,
+ 'X-Request-ID': generateRequestId()
+ }
+ };
+ },
+
+ // Replace the default fetch implementation
+ makeRequest: async (params) => {
+ // Use axios, got, or any HTTP client
+ const axiosResponse = await axios({
+ url: params.url,
+ method: params.method,
+ headers: params.headers,
+ data: params.body
+ });
+ return {
+ ok: axiosResponse.status >= 200 && axiosResponse.status < 300,
+ status: axiosResponse.status,
+ statusText: axiosResponse.statusText,
+ headers: axiosResponse.headers,
+ json: () => axiosResponse.data
+ };
+ },
+
+ // Process response after receiving
+ afterResponse: async (response, params) => {
+ console.log(`Response ${response.status} from ${params.url}`);
+ return response;
+ },
+
+ // Handle errors
+ onError: async (error, params) => {
+ console.error(`Request failed: ${error.message}`);
+ // Optionally transform the error
+ return error;
+ }
+ }
+});
+```
+
+## Path Parameters
+
+For operations with path parameters, the generator creates typed parameter classes:
+
+```typescript
+import { UserItemsParameters } from './__gen__/parameters/UserItemsParameters';
+
+// Create parameters with type safety
+const params = new UserItemsParameters({
+ userId: 'user-123',
+ itemId: 456
+});
+
+const response = await getGetUserItem({
+ server: 'https://api.example.com',
+ parameters: params // Replaces {userId} and {itemId} in path
+});
+```
+
+## Typed Headers
+
+For operations with defined headers, the generator creates typed header classes:
+
+```typescript
+import { ItemRequestHeaders } from './__gen__/headers/ItemRequestHeaders';
+
+const headers = new ItemRequestHeaders({
+ xCorrelationId: 'corr-123',
+ xRequestId: 'req-456'
+});
+
+const response = await putUpdateUserItem({
+ server: 'https://api.example.com',
+ parameters: params,
+ payload: itemData,
+ requestHeaders: headers // Type-safe headers
+});
+```
+
+## Additional Headers and Query Parameters
+
+Add custom headers or query parameters to any request:
+
+```typescript
+const response = await postPingPostRequest({
+ payload: message,
+ server: 'https://api.example.com',
+ additionalHeaders: {
+ 'X-Custom-Header': 'value',
+ 'Accept-Language': 'en-US'
+ },
+ queryParams: {
+ include: 'metadata',
+ format: 'detailed'
+ }
+});
+```
+
+## Multi-Status Responses
+
+For operations that return different payloads based on status code, the generator creates union types:
+
+```yaml
+# AsyncAPI spec with multiple response types
+operations:
+ getItem:
+ reply:
+ messages:
+ - $ref: '#/components/messages/ItemResponse' # 200
+ - $ref: '#/components/messages/NotFoundError' # 404
+```
+
+```typescript
+const response = await getItemRequest({
+ server: 'https://api.example.com',
+ parameters: params
+});
+
+// Response type is union: ItemResponse | NotFoundError
+// Use response.status to discriminate
+if (response.status === 200) {
+ console.log('Item:', response.data); // ItemResponse
+} else if (response.status === 404) {
+ console.log('Not found:', response.data); // NotFoundError
+}
+```
\ No newline at end of file
diff --git a/src/codegen/generators/typescript/channels/asyncapi.ts b/src/codegen/generators/typescript/channels/asyncapi.ts
index 3b2587f1..1dbf8ba8 100644
--- a/src/codegen/generators/typescript/channels/asyncapi.ts
+++ b/src/codegen/generators/typescript/channels/asyncapi.ts
@@ -23,7 +23,10 @@ import {generateKafkaChannels} from './protocols/kafka';
import {generateMqttChannels} from './protocols/mqtt';
import {generateAmqpChannels} from './protocols/amqp';
import {generateEventSourceChannels} from './protocols/eventsource';
-import {generatehttpChannels} from './protocols/http';
+import {
+ generatehttpChannels,
+ resetHttpCommonTypesState
+} from './protocols/http';
import {generateWebSocketChannels} from './protocols/websocket';
type Action = 'send' | 'receive' | 'subscribe' | 'publish';
@@ -85,6 +88,11 @@ export async function generateTypeScriptChannelsForAsyncAPI(
>,
protocolDependencies: Record
): Promise {
+ // Reset protocol-specific state at the start of each generation cycle
+ if (protocolsToUse.includes('http_client')) {
+ resetHttpCommonTypesState();
+ }
+
const {asyncapiDocument} = validateAsyncapiContext(context);
const channels = asyncapiDocument!
.allChannels()
diff --git a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts
index dc494b90..00000ea1 100644
--- a/src/codegen/generators/typescript/channels/protocols/http/fetch.ts
+++ b/src/codegen/generators/typescript/channels/protocols/http/fetch.ts
@@ -2,440 +2,1046 @@ import {HttpRenderType} from '../../../../../types';
import {pascalCase} from '../../../utils';
import {ChannelFunctionTypes, RenderHttpParameters} from '../../types';
-export function renderHttpFetchClient({
- requestTopic,
- requestMessageType,
- requestMessageModule,
- replyMessageType,
- replyMessageModule,
- channelParameters,
- method,
- statusCodes = [],
- servers = [],
- subName = pascalCase(requestTopic),
- functionName = `${method.toLowerCase()}${subName}`
-}: RenderHttpParameters): HttpRenderType {
- const addressToUse = channelParameters
- ? `parameters.getChannelWithParameters('${requestTopic}')`
- : `'${requestTopic}'`;
- const messageType = requestMessageModule
- ? `${requestMessageModule}.${requestMessageType}`
- : requestMessageType;
- const replyType = replyMessageModule
- ? `${replyMessageModule}.${replyMessageType}`
- : replyMessageType;
- const code = `async function ${functionName}(context: {
- server?: ${[...servers.map((value) => `'${value}'`), 'string'].join(' | ')};
- ${messageType ? `payload: ${messageType};` : ''}
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise<${replyType}> {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: ${addressToUse},
- server: ${servers[0] ?? "'localhost:3000'"},
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
- }
+/**
+ * Generates common types and helper functions shared across all HTTP client functions.
+ * This should be called once per protocol generation to avoid code duplication.
+ */
+export function renderHttpCommonTypes(): string {
+ return `// ============================================================================
+// Common Types - Shared across all HTTP client functions
+// ============================================================================
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
+/**
+ * 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;
+}
+
+// ============================================================================
+// 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;
+
+// ============================================================================
+// 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;
}
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
+
+ case 'apiKey': {
+ const keyName = auth.name ?? 'X-API-Key';
+ const keyIn = auth.in ?? 'header';
+
+ if (keyIn === 'header') {
+ headers[keyName] = auth.key;
+ } else {
+ const separator = url.includes('?') ? '&' : '?';
+ url = \`\${url}\${separator}\${keyName}=\${encodeURIComponent(auth.key)}\`;
+ }
+ break;
}
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
+
+ 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;
}
}
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
- }
+ 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;
}
+}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
+/**
+ * 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);
}
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
+ };
+
+ 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;
}
}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
- };
- let url = \`\${parsedContext.server}\${parsedContext.path}\`;
-
- let body: any;
- ${
- messageType
- ? `if (parsedContext.payload) {
- body = parsedContext.payload.marshal();
- }`
- : ''
- }
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64');
- headers["Authorization"] = \`Basic \${credentials}\`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`;
+ // 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));
}
}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: '${method}',
- headers,
- body
- });
+ const paramString = params.toString();
+ if (!paramString) return url;
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ const separator = url.includes('?') ? '&' : '?';
+ return \`\${url}\${separator}\${paramString}\`;
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+/**
+ * Sleep for a specified number of milliseconds
+ */
+function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+/**
+ * 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);
+}
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- \`\${parsedContext.oauth2.clientId}:\${parsedContext.oauth2.clientSecret}\`
- ).toString('base64');
- authHeaders['Authorization'] = \`Basic \${credentials}\`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
- }
+/**
+ * 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;
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ if (error && config.retryOnNetworkError) return true;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ if (response && config.retryableStatusCodes.includes(response.status)) return true;
- // Update headers with the new token
- headers["Authorization"] = \`Bearer \${tokens.accessToken}\`;
+ return false;
+}
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+/**
+ * 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;
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "${method}",
- headers,
- body
- });
+ 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 data = await retryResponse.json();
- return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, retryResponse.status)` : `${replyMessageType}.unmarshal(data)`};
- } else {
- return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`));
+ 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) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ lastError = error instanceof Error ? error : new Error(String(error));
+
+ if (!shouldRetry(lastError, null, config, attempt + 1)) {
+ throw lastError;
+ }
}
}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+ // All retries exhausted
+ if (lastResponse) {
+ return lastResponse;
+ }
+ throw lastError ?? new Error('Request failed after retries');
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+/**
+ * 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;
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ const params = new URLSearchParams();
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ 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(' '));
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ const authHeaders: Record = {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ };
- // Update headers with the new token
- headers["Authorization"] = \`Bearer \${tokens.accessToken}\`;
+ // 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');
+ }
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ const tokenResponse = await NodeFetch.default(auth.tokenUrl, {
+ method: 'POST',
+ headers: authHeaders,
+ body: params.toString()
+ });
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "${method}",
- headers,
- body
- });
+ if (!tokenResponse.ok) {
+ throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`);
+ }
- const data = await retryResponse.json();
- return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, retryResponse.status)` : `${replyMessageType}.unmarshal(data)`};
+ const tokenData = await tokenResponse.json();
+ const tokens: TokenResponse = {
+ accessToken: tokenData.access_token,
+ refreshToken: tokenData.refresh_token,
+ expiresIn: tokenData.expires_in
+ };
- } else {
- return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`));
- }
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
- }
+ // Notify the client about the tokens
+ if (auth.onTokenRefresh) {
+ auth.onTokenRefresh(tokens);
}
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
+ // 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
+ */
+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;
});
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = \`Bearer \${newTokens.accessToken}\`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
- }
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "${method}",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return ${replyMessageModule ? `${replyMessageModule}.unmarshalByStatusCode(data, retryResponse.status)` : `${replyMessageType}.unmarshal(data)`};
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ } else {
+ // Plain object
+ for (const [key, value] of Object.entries(response.headers)) {
+ headers[key.toLowerCase()] = value;
}
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
}
}
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- ${
- replyMessageModule
- ? ''
- : `// Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`));
- }`
+
+ 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;
}
}
-
- ${
- replyMessageModule
- ? `// For multi-status responses, always try to parse JSON and let unmarshalByStatusCode handle it
- try {
- const data = await response.json();
- return ${replyMessageModule}.unmarshalByStatusCode(data, response.status);
- } catch (error) {
- // If JSON parsing fails or unmarshalByStatusCode fails, provide standardized error messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`));
+
+ // 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;
}
- }`
- : `const data = await response.json();
- return ${replyMessageType}.unmarshal(data);`
+ 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;
+}
+
+// ============================================================================
+// Generated HTTP Client Functions
+// ============================================================================`;
+}
+
+export function renderHttpFetchClient({
+ requestTopic,
+ requestMessageType,
+ requestMessageModule,
+ replyMessageType,
+ replyMessageModule,
+ channelParameters,
+ method,
+ statusCodes = [],
+ servers = [],
+ subName = pascalCase(requestTopic),
+ functionName = `${method.toLowerCase()}${subName}`
+}: 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
+ });
+
+ const code = `${contextInterface}
+
+${functionCode}`;
+
return {
messageType,
replyType,
@@ -448,3 +1054,204 @@ export function renderHttpFetchClient({
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[];
+}): string {
+ const {
+ functionName,
+ contextInterfaceName,
+ replyType,
+ replyMessageModule,
+ replyMessageType,
+ messageType,
+ requestTopic,
+ hasParameters,
+ method,
+ servers
+ } = 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
+ const responseParseCode = replyMessageModule
+ ? `const responseData = ${replyMessageModule}.unmarshalByStatusCode(rawData, response.status);`
+ : `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') {
+ 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) {
+ 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') {
+ 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 b499bd45..d96d1380 100644
--- a/src/codegen/generators/typescript/channels/protocols/http/index.ts
+++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts
@@ -17,9 +17,20 @@ import {
import {ChannelInterface} from '@asyncapi/parser';
import {HttpRenderType, SingleFunctionRenderType} from '../../../../../types';
import {ConstrainedObjectModel} from '@asyncapi/modelina';
-import {renderHttpFetchClient} from './fetch';
+import {renderHttpFetchClient, renderHttpCommonTypes} from './fetch';
-export {renderHttpFetchClient};
+export {renderHttpFetchClient, renderHttpCommonTypes};
+
+// Track whether common types have been generated for this protocol
+let httpCommonTypesGenerated = false;
+
+/**
+ * Reset the common types generation state.
+ * Called at the start of each generation cycle.
+ */
+export function resetHttpCommonTypesState(): void {
+ httpCommonTypesGenerated = false;
+}
export async function generatehttpChannels(
context: TypeScriptChannelsGeneratorContext,
@@ -38,6 +49,15 @@ export async function generatehttpChannels(
if (operations.length > 0 && !ignoreOperation) {
renders = generateForOperations(context, channel, topic, parameter);
}
+
+ // Generate common types once for the HTTP protocol
+ if (!httpCommonTypesGenerated && renders.length > 0) {
+ const commonTypesCode = renderHttpCommonTypes();
+ // Prepend common types to the beginning of the protocol code
+ protocolCodeFunctions['http_client'].unshift(commonTypesCode);
+ httpCommonTypesGenerated = true;
+ }
+
addRendersToExternal(
renders,
protocolCodeFunctions,
@@ -103,11 +123,8 @@ function generateForOperations(
operation.bindings().get('http')?.json()['method'] ?? 'GET';
const payloadId = findOperationId(operation, channel);
const payload = payloads.operationModels[payloadId];
- if (payload === undefined && httpMethod === 'POST') {
- throw new Error(
- `Could not find payload for ${payloadId} for channel typescript generator ${JSON.stringify(payloads.operationModels, null, 4)}`
- );
- }
+ const methodsWithBody = ['POST', 'PUT', 'PATCH'];
+ const hasBody = methodsWithBody.includes(httpMethod.toUpperCase());
const {messageModule, messageType} = getMessageTypeAndModule(payload);
const reply = operation.reply();
if (reply) {
@@ -145,9 +162,8 @@ function generateForOperations(
renders.push(
renderHttpFetchClient({
subName: findNameFromOperation(operation, channel),
- requestMessageModule:
- httpMethod === 'POST' ? messageModule : undefined,
- requestMessageType: httpMethod === 'POST' ? messageType : undefined,
+ requestMessageModule: hasBody ? messageModule : undefined,
+ requestMessageType: hasBody ? messageType : undefined,
replyMessageModule,
replyMessageType,
requestTopic: topic,
diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap
index 318589d7..33056eae 100644
--- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap
+++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap
@@ -551,390 +551,1104 @@ exports[`channels typescript protocol-specific code generation should generate H
import { URLSearchParams, URL } from 'url';
import * as NodeFetch from 'node-fetch';
-async function getPingRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
- }
+// ============================================================================
+// Common Types - Shared across all HTTP client functions
+// ============================================================================
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
- }
+/**
+ * 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;
+}
+
+// ============================================================================
+// 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;
+
+// ============================================================================
+// 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;
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
+ // 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 ?? 'X-API-Key';
+ const keyIn = auth.in ?? 'header';
+
+ if (keyIn === 'header') {
+ headers[keyName] = auth.key;
+ } else {
+ const separator = url.includes('?') ? '&' : '?';
+ url = \`\${url}\${separator}\${keyName}=\${encodeURIComponent(auth.key)}\`;
+ }
+ break;
}
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
+
+ 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;
}
}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
+ 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;
}
+}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
+/**
+ * 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);
+ }
};
- let url = \`\${parsedContext.server}\${parsedContext.path}\`;
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = \`Bearer \${parsedContext.oauth2.accessToken}\`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
+ 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;
}
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = \`Bearer \${parsedContext.bearerToken}\`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(\`\${parsedContext.username}:\${parsedContext.password}\`).toString('base64');
- headers["Authorization"] = \`Basic \${credentials}\`;
}
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = \`\${url}\${separator}\${parsedContext.apiKeyName}=\${encodeURIComponent(parsedContext.apiKey)}\`;
+
+ // 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));
}
}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'GET',
- headers,
- body
- });
+ const paramString = params.toString();
+ if (!paramString) return url;
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ 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);
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
+/**
+ * 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);
}
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
+ const response = await makeRequest(params);
+
+ // Check if we should retry this response
+ if (!shouldRetry(null, response, config, attempt + 1)) {
+ return response;
}
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
+ lastResponse = response;
+ lastError = new Error(\`HTTP Error: \${response.status} \${response.statusText}\`);
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- \`\${parsedContext.oauth2.clientId}:\${parsedContext.oauth2.clientSecret}\`
- ).toString('base64');
- authHeaders['Authorization'] = \`Basic \${credentials}\`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
+ if (!shouldRetry(lastError, null, config, attempt + 1)) {
+ throw lastError;
}
+ }
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ // All retries exhausted
+ if (lastResponse) {
+ return lastResponse;
+ }
+ throw lastError ?? new Error('Request failed after retries');
+}
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+/**
+ * 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;
+ }
- // Update headers with the new token
- headers["Authorization"] = \`Bearer \${tokens.accessToken}\`;
+ if (auth.clientSecret) {
+ params.append('client_secret', auth.clientSecret);
+ }
+ if (auth.scopes && auth.scopes.length > 0) {
+ params.append('scope', auth.scopes.join(' '));
+ }
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ const authHeaders: Record = {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ };
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
+ // 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 data = await retryResponse.json();
- return PongModule.unmarshalByStatusCode(data, retryResponse.status);
- } else {
- return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`));
+ 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
+ */
+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;
}
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
}
}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+ return headers;
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+/**
+ * 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;
+ }
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ // Total pages
+ const totalPages = headers['x-total-pages'] || headers['x-page-count'];
+ if (totalPages) {
+ info.totalPages = parseInt(totalPages, 10);
+ hasPaginationInfo = true;
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Next cursor
+ const nextCursor = headers['x-next-cursor'] || headers['x-cursor-next'];
+ if (nextCursor) {
+ info.nextCursor = nextCursor;
+ info.hasMore = true;
+ hasPaginationInfo = true;
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Previous cursor
+ const prevCursor = headers['x-prev-cursor'] || headers['x-cursor-prev'];
+ if (prevCursor) {
+ info.prevCursor = prevCursor;
+ hasPaginationInfo = true;
+ }
- // Update headers with the new token
- headers["Authorization"] = \`Bearer \${tokens.accessToken}\`;
+ // Has more indicator
+ const hasMore = headers['x-has-more'] || headers['x-has-next'];
+ if (hasMore) {
+ info.hasMore = hasMore.toLowerCase() === 'true' || hasMore === '1';
+ hasPaginationInfo = true;
+ }
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Parse Link header (RFC 5988)
+ const linkHeader = headers['link'];
+ if (linkHeader) {
+ const links = parseLinkHeader(linkHeader);
+ if (links.next) {
+ info.hasMore = true;
+ hasPaginationInfo = true;
+ }
+ }
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
+ // 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;
+ }
- const data = await retryResponse.json();
- return PongModule.unmarshalByStatusCode(data, retryResponse.status);
+ // 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;
+ }
- } else {
- return Promise.reject(new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`));
- }
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
+ 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];
}
}
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = \`Bearer \${newTokens.accessToken}\`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
- }
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return PongModule.unmarshalByStatusCode(data, retryResponse.status);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ 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;
}
}
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
-
+
+ return headers;
+}
+
+// ============================================================================
+// Generated HTTP Client Functions
+// ============================================================================
+
+export interface GetPingRequestContext extends HttpClientContext {
+ requestHeaders?: { marshal: () => string };
+}
+
+async function getPingRequest(context: GetPingRequestContext = {}): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
+ };
+
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
}
-
- // For multi-status responses, always try to parse JSON and let unmarshalByStatusCode handle it
+
+ // 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 = undefined;
+
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
+
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'GET',
+ headers,
+ body
+ };
+
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
+
try {
- const data = await response.json();
- return PongModule.unmarshalByStatusCode(data, response.status);
+ // 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) {
+ 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') {
+ 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();
+ const responseData = PongModule.unmarshalByStatusCode(rawData, response.status);
+
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
+
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, getPingRequest),
+ };
+
+ return result;
+
} catch (error) {
- // If JSON parsing fails or unmarshalByStatusCode fails, provide standardized error messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(\`HTTP Error: \${response.status} \${response.statusText}\`));
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
}
diff --git a/test/runtime/asyncapi-request-reply.json b/test/runtime/asyncapi-request-reply.json
index fd1571d1..5d718c5c 100644
--- a/test/runtime/asyncapi-request-reply.json
+++ b/test/runtime/asyncapi-request-reply.json
@@ -18,6 +18,28 @@
"$ref": "#/components/messages/notFound"
}
}
+ },
+ "userItems": {
+ "address": "/users/{userId}/items/{itemId}",
+ "parameters": {
+ "userId": {
+ "description": "The unique identifier of the user"
+ },
+ "itemId": {
+ "description": "The unique identifier of the item"
+ }
+ },
+ "messages": {
+ "itemRequest": {
+ "$ref": "#/components/messages/itemRequest"
+ },
+ "itemResponse": {
+ "$ref": "#/components/messages/itemResponse"
+ },
+ "notFound": {
+ "$ref": "#/components/messages/notFound"
+ }
+ }
}
},
"operations": {
@@ -253,6 +275,56 @@
"x-the-codegen-project": {
"functionTypeMapping": ["http_client"]
}
+ },
+ "getUserItem": {
+ "action": "send",
+ "channel": {
+ "$ref": "#/channels/userItems"
+ },
+ "messages": [],
+ "bindings": {
+ "http": {
+ "method": "GET"
+ }
+ },
+ "reply": {
+ "channel": {
+ "$ref": "#/channels/userItems"
+ },
+ "messages": [
+ {"$ref": "#/channels/userItems/messages/itemResponse"},
+ {"$ref": "#/channels/userItems/messages/notFound"}
+ ]
+ },
+ "x-the-codegen-project": {
+ "functionTypeMapping": ["http_client"]
+ }
+ },
+ "updateUserItem": {
+ "action": "send",
+ "channel": {
+ "$ref": "#/channels/userItems"
+ },
+ "messages": [
+ {"$ref": "#/channels/userItems/messages/itemRequest"}
+ ],
+ "bindings": {
+ "http": {
+ "method": "PUT"
+ }
+ },
+ "reply": {
+ "channel": {
+ "$ref": "#/channels/userItems"
+ },
+ "messages": [
+ {"$ref": "#/channels/userItems/messages/itemResponse"},
+ {"$ref": "#/channels/userItems/messages/notFound"}
+ ]
+ },
+ "x-the-codegen-project": {
+ "functionTypeMapping": ["http_client"]
+ }
}
},
"components": {
@@ -303,6 +375,85 @@
"statusCode": 404
}
}
+ },
+ "itemRequest": {
+ "headers": {
+ "type": "object",
+ "properties": {
+ "x-correlation-id": {
+ "type": "string",
+ "description": "Correlation ID for request tracing"
+ },
+ "x-request-id": {
+ "type": "string",
+ "description": "Unique request identifier"
+ }
+ },
+ "required": ["x-correlation-id"]
+ },
+ "payload": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the item"
+ },
+ "description": {
+ "type": "string",
+ "description": "Item description"
+ },
+ "quantity": {
+ "type": "integer",
+ "description": "Item quantity"
+ }
+ },
+ "required": ["name"]
+ }
+ },
+ "itemResponse": {
+ "headers": {
+ "type": "object",
+ "properties": {
+ "x-correlation-id": {
+ "type": "string",
+ "description": "Correlation ID echoed back"
+ },
+ "x-response-time": {
+ "type": "string",
+ "description": "Server response time in ms"
+ }
+ }
+ },
+ "payload": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The item ID"
+ },
+ "userId": {
+ "type": "string",
+ "description": "Owner user ID"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the item"
+ },
+ "description": {
+ "type": "string",
+ "description": "Item description"
+ },
+ "quantity": {
+ "type": "integer",
+ "description": "Item quantity"
+ }
+ }
+ },
+ "bindings": {
+ "http": {
+ "statusCode": 200
+ }
+ }
}
},
"schemas": {
diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json
index f8d4700b..a2d539ec 100644
--- a/test/runtime/typescript/package.json
+++ b/test/runtime/typescript/package.json
@@ -10,10 +10,7 @@
"test:amqp": "jest -- ./test/channels/regular/amqp.spec.ts",
"test:eventsource": "jest -- ./test/channels/regular/eventsource.spec.ts",
"test:websocket": "jest -- ./test/channels/regular/websocket.spec.ts",
- "test:http": "jest -- ./test/channels/request_reply/http_client/http_client.spec.ts ./test/channels/request_reply/http_client/api_auth.spec.ts ./test/channels/request_reply/http_client/oauth2_client_credentials.spec.ts ./test/channels/request_reply/http_client/oauth2_implicit_flow.spec.ts ./test/channels/request_reply/http_client/oauth2_password_flow.spec.ts ./test/channels/request_reply/http_client/oauth2_refresh_token.spec.ts",
- "test:http:basic": "jest -- ./test/channels/request_reply/http_client/basic_http_methods.spec.ts",
- "test:http:auth": "jest -- ./test/channels/request_reply/http_client/api_auth.spec.ts",
- "test:http:oauth2": "jest -- ./test/channels/http_client/oauth2_client_credentials.spec.ts ./test/channels/http_client/oauth2_implicit_flow.spec.ts ./test/channels/http_client/oauth2_password_flow.spec.ts ./test/channels/http_client/oauth2_refresh_token.spec.ts",
+ "test:http": "jest -- ./test/channels/request_reply/http_client/",
"generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi",
"generate:request:reply": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-request-reply.mjs",
"generate:regular": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-regular.mjs",
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 a1043adb..5fc01239 100644
--- a/test/runtime/typescript/src/request-reply/channels/http_client.ts
+++ b/test/runtime/typescript/src/request-reply/channels/http_client.ts
@@ -1,3072 +1,2194 @@
import {Pong} from './../payloads/Pong';
import {Ping} from './../payloads/Ping';
import * as MultiStatusResponseReplyPayloadModule from './../payloads/MultiStatusResponseReplyPayload';
+import * as GetUserItemReplyPayloadModule from './../payloads/GetUserItemReplyPayload';
+import * as UpdateUserItemReplyPayloadModule from './../payloads/UpdateUserItemReplyPayload';
+import {ItemRequest} from './../payloads/ItemRequest';
import * as PingPayloadModule from './../payloads/PingPayload';
+import * as UserItemsPayloadModule from './../payloads/UserItemsPayload';
import {NotFound} from './../payloads/NotFound';
+import {ItemResponse} from './../payloads/ItemResponse';
+import {UserItemsParameters} from './../parameters/UserItemsParameters';
+import {ItemRequestHeaders} from './../headers/ItemRequestHeaders';
import { URLSearchParams, URL } from 'url';
import * as NodeFetch from 'node-fetch';
-async function postPingPostRequest(context: {
- server?: string;
- payload: Ping;
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
- }
-
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
- }
-
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
- }
- }
+// ============================================================================
+// 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>;
+}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
- }
+/**
+ * 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;
+}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
- };
- let url = `${parsedContext.server}${parsedContext.path}`;
+/**
+ * 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;
+}
- let body: any;
- if (parsedContext.payload) {
- body = parsedContext.payload.marshal();
- }
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
- }
- }
+/**
+ * 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;
+}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'POST',
- headers,
- body
- });
+/**
+ * Token response structure for OAuth2 flows
+ */
+export interface TokenResponse {
+ accessToken: string;
+ refreshToken?: string;
+ expiresIn?: number;
+}
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+// ============================================================================
+// Security Configuration Types - Grouped for better autocomplete
+// ============================================================================
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+/**
+ * Bearer token authentication configuration
+ */
+export interface BearerAuth {
+ type: 'bearer';
+ token: string;
+}
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+/**
+ * Basic authentication configuration (username/password)
+ */
+export interface BasicAuth {
+ type: 'basic';
+ username: string;
+ password: string;
+}
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
- }
+/**
+ * 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')
+}
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+/**
+ * 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;
+}
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+/**
+ * Union type for all authentication methods - provides autocomplete support
+ */
+export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth;
+
+// ============================================================================
+// 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)
+}
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+/**
+ * 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)
+}
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+/**
+ * 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)
+}
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "POST",
- headers,
- body
- });
+/**
+ * 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')
+}
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
- }
- }
+/**
+ * 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
+}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+// ============================================================================
+// 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;
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+// ============================================================================
+// Common Request Context
+// ============================================================================
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+/**
+ * Base context shared by all HTTP client functions
+ */
+export interface HttpClientContext {
+ server?: string;
+ path?: string;
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Authentication - grouped for better autocomplete
+ auth?: AuthConfig;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Pagination configuration
+ pagination?: PaginationConfig;
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Retry configuration
+ retry?: RetryConfig;
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Hooks for extensibility
+ hooks?: HttpHooks;
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "POST",
- headers,
- body
- });
+ // Additional options
+ additionalHeaders?: Record;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
+ // 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 ?? 'X-API-Key';
+ const keyIn = auth.in ?? 'header';
+
+ if (keyIn === 'header') {
+ headers[keyName] = auth.key;
} else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
+ const separator = url.includes('?') ? '&' : '?';
+ url = `${url}${separator}${keyName}=${encodeURIComponent(auth.key)}`;
}
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
+ break;
}
- }
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
- }
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "POST",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ 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}`;
}
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ break;
}
}
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
- }
- }
-
- const data = await response.json();
- return Pong.unmarshal(data);
+
+ return { headers, url };
}
-async function getPingGetRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
+/**
+ * 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;
}
+}
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
+/**
+ * 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);
}
- }
+ };
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
- }
- }
+ 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 };
+}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
- }
+/**
+ * Apply query parameters to URL
+ */
+function applyQueryParams(queryParams: Record | undefined, url: string): string {
+ if (!queryParams) return url;
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
- };
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(queryParams)) {
+ if (value !== undefined) {
+ params.append(key, String(value));
}
}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'GET',
- headers,
- body
- });
+ const paramString = params.toString();
+ if (!paramString) return url;
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ const separator = url.includes('?') ? '&' : '?';
+ return `${url}${separator}${paramString}`;
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+/**
+ * Sleep for a specified number of milliseconds
+ */
+function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+/**
+ * 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);
+}
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
- }
+/**
+ * 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;
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ if (error && config.retryOnNetworkError) return true;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ if (response && config.retryableStatusCodes.includes(response.status)) return true;
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ return false;
+}
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+/**
+ * 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);
+ }
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
+ const response = await makeRequest(params);
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
+ // 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) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ lastError = error instanceof Error ? error : new Error(String(error));
+
+ if (!shouldRetry(lastError, null, config, attempt + 1)) {
+ throw lastError;
+ }
}
}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
-
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ // All retries exhausted
+ if (lastResponse) {
+ return lastResponse;
+ }
+ throw lastError ?? new Error('Request failed after retries');
+}
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+/**
+ * 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 tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ const params = new URLSearchParams();
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ 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;
+ }
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ if (auth.clientSecret) {
+ params.append('client_secret', auth.clientSecret);
+ }
+ if (auth.scopes && auth.scopes.length > 0) {
+ params.append('scope', auth.scopes.join(' '));
+ }
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ const authHeaders: Record = {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ };
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
+ // 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 data = await retryResponse.json();
- return Pong.unmarshal(data);
+ const tokenResponse = await NodeFetch.default(auth.tokenUrl, {
+ method: 'POST',
+ headers: authHeaders,
+ body: params.toString()
+ });
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
- }
+ if (!tokenResponse.ok) {
+ throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`);
}
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
- }
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.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);
}
-
- const data = await response.json();
- return Pong.unmarshal(data);
+
+ // Retry the original request with the new token
+ const updatedHeaders = { ...originalParams.headers };
+ updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`;
+
+ return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig);
}
-async function putPingPutRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
+/**
+ * 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'
},
- ...context,
+ 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);
}
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
+ // 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
+ */
+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}`);
}
+}
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
+/**
+ * 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;
+ }
}
}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
+ 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];
}
}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
- };
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
+ 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;
}
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
+
+ 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;
+ };
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'PUT',
- headers,
- body
- });
+ helpers.hasPrevPage = () => {
+ if (paginationInfo?.prevCursor) return true;
+ if (paginationInfo?.currentOffset !== undefined) {
+ return paginationInfo.currentOffset > 0;
+ }
+ return false;
+ };
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ 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 });
+ };
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ 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 });
+ };
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ return helpers;
+}
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
- }
+/**
+ * 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}`;
+}
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+/**
+ * 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 (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ 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;
+ }
+ }
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ return headers;
+}
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+// ============================================================================
+// Generated HTTP Client Functions
+// ============================================================================
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "PUT",
- headers,
- body
- });
+export interface PostPingPostRequestContext extends HttpClientContext {
+ payload: Ping;
+ requestHeaders?: { marshal: () => string };
+}
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
- }
+async function postPingPostRequest(context: PostPingPostRequestContext): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
+ };
+
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ // Build URL
+ let url = `${config.server}${config.path}`;
+ url = applyQueryParams(config.queryParams, url);
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Apply authentication
+ const authResult = applyAuth(config.auth, headers, url);
+ headers = authResult.headers;
+ url = authResult.url;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Prepare body
+ const body = context.payload?.marshal();
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'POST',
+ headers,
+ body
+ };
+
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "PUT",
- headers,
- body
- });
+ try {
+ // Execute request with retry logic
+ let response = await executeWithRetry(requestParams, makeRequest, config.retry);
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
+ // Apply afterResponse hook
+ if (config.hooks?.afterResponse) {
+ response = await config.hooks.afterResponse(response, requestParams);
+ }
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
+ // Handle OAuth2 token flows that require getting a token first
+ if (config.auth?.type === 'oauth2' && !config.auth.accessToken) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
}
- }
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
}
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "PUT",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ } catch {
+ throw new Error('Unauthorized');
}
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
}
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
+
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
}
- }
-
- const data = await response.json();
- return Pong.unmarshal(data);
-}
-async function deletePingDeleteRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
+
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
+
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, postPingPostRequest),
};
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
- }
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
- }
+ return result;
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
- }
+export interface GetPingGetRequestContext extends HttpClientContext {
+ requestHeaders?: { marshal: () => string };
+}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
+async function getPingGetRequest(context: GetPingGetRequestContext = {}): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
};
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
- }
- }
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'DELETE',
- headers,
- body
- });
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
+ }
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ // Build URL
+ let url = `${config.server}${config.path}`;
+ url = applyQueryParams(config.queryParams, url);
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
- }
+ // Apply authentication
+ const authResult = applyAuth(config.auth, headers, url);
+ headers = authResult.headers;
+ url = authResult.url;
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ // Prepare body
+ const body = undefined;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'GET',
+ headers,
+ body
+ };
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "DELETE",
- headers,
- body
- });
+ try {
+ // Execute request with retry logic
+ let response = await executeWithRetry(requestParams, makeRequest, config.retry);
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ // Apply afterResponse hook
+ if (config.hooks?.afterResponse) {
+ response = await config.hooks.afterResponse(response, requestParams);
}
- }
-
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
+ // Handle OAuth2 token flows that require getting a token first
+ if (config.auth?.type === 'oauth2' && !config.auth.accessToken) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
+ }
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
+ }
+ } catch {
+ throw new Error('Unauthorized');
}
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
-
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
+ }
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "DELETE",
- headers,
- body
- });
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, getPingGetRequest),
+ };
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
+ return result;
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
- }
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "DELETE",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
- }
- }
-
- const data = await response.json();
- return Pong.unmarshal(data);
+export interface PutPingPutRequestContext extends HttpClientContext {
+ payload: Ping;
+ requestHeaders?: { marshal: () => string };
}
-async function patchPingPatchRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
+async function putPingPutRequest(context: PutPingPutRequestContext): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
...context,
- }
+ };
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
}
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
- }
- }
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
- }
+ // Build URL
+ let url = `${config.server}${config.path}`;
+ url = applyQueryParams(config.queryParams, url);
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
- };
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
- }
- }
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'PATCH',
+ // 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: 'PUT',
headers,
body
- });
+ };
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ try {
+ // Execute request with retry logic
+ let response = await executeWithRetry(requestParams, makeRequest, config.retry);
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
+ // 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) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
+ }
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
+ }
+ } catch {
+ throw new Error('Unauthorized');
}
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, putPingPutRequest),
+ };
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "PATCH",
- headers,
- body
- });
+ return result;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
-
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+export interface DeletePingDeleteRequestContext extends HttpClientContext {
+ requestHeaders?: { marshal: () => string };
+}
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+async function deletePingDeleteRequest(context: DeletePingDeleteRequestContext = {}): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
+ };
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Build URL
+ let url = `${config.server}${config.path}`;
+ url = applyQueryParams(config.queryParams, url);
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "PATCH",
- headers,
- body
- });
+ // Apply authentication
+ const authResult = applyAuth(config.auth, headers, url);
+ headers = authResult.headers;
+ url = authResult.url;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
+ // Prepare body
+ const body = undefined;
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
- }
- }
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
- }
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "PATCH",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
- }
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
- }
- }
-
- const data = await response.json();
- return Pong.unmarshal(data);
-}
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'DELETE',
+ headers,
+ body
+ };
-async function headPingHeadRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
- };
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
}
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
- }
+ try {
+ // Execute request with retry logic
+ let response = await executeWithRetry(requestParams, makeRequest, config.retry);
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
+ // Apply afterResponse hook
+ if (config.hooks?.afterResponse) {
+ response = await config.hooks.afterResponse(response, requestParams);
}
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
- }
- }
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
+ // Handle OAuth2 token flows that require getting a token first
+ if (config.auth?.type === 'oauth2' && !config.auth.accessToken) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
+ }
}
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
+
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
+ }
+ } catch {
+ throw new Error('Unauthorized');
+ }
}
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
+
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
}
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
+
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
+
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
+
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, deletePingDeleteRequest),
+ };
+
+ 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;
}
+}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
+export interface PatchPingPatchRequestContext extends HttpClientContext {
+ payload: Ping;
+ requestHeaders?: { marshal: () => string };
+}
+
+async function patchPingPatchRequest(context: PatchPingPatchRequestContext): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
};
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
- }
+
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
- method: 'HEAD',
+ // 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: 'PATCH',
headers,
body
- });
+ };
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ try {
+ // Execute request with retry logic
+ let response = await executeWithRetry(requestParams, makeRequest, config.retry);
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
+ // 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) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
+ }
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
+ }
+ } catch {
+ throw new Error('Unauthorized');
}
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, patchPingPatchRequest),
+ };
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "HEAD",
- headers,
- body
- });
+ return result;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+export interface HeadPingHeadRequestContext extends HttpClientContext {
+ requestHeaders?: { marshal: () => string };
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+async function headPingHeadRequest(context: HeadPingHeadRequestContext = {}): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
+ };
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Build URL
+ let url = `${config.server}${config.path}`;
+ url = applyQueryParams(config.queryParams, url);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Apply authentication
+ const authResult = applyAuth(config.auth, headers, url);
+ headers = authResult.headers;
+ url = authResult.url;
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "HEAD",
- headers,
- body
- });
+ // Prepare body
+ const body = undefined;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'HEAD',
+ 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) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
}
- }
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
}
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "HEAD",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ } catch {
+ throw new Error('Unauthorized');
}
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
}
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
+
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
}
- }
-
- const data = await response.json();
- return Pong.unmarshal(data);
-}
-async function optionsPingOptionsRequest(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
+
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
+
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, headPingHeadRequest),
};
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
- }
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
- }
+ return result;
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
- }
+export interface OptionsPingOptionsRequestContext extends HttpClientContext {
+ requestHeaders?: { marshal: () => string };
+}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
+async function optionsPingOptionsRequest(context: OptionsPingOptionsRequestContext = {}): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
};
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
- }
+
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
+ // 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 = undefined;
+
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
+
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
method: 'OPTIONS',
headers,
body
- });
+ };
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ 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);
+ }
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
+ // Handle OAuth2 token flows that require getting a token first
+ if (config.auth?.type === 'oauth2' && !config.auth.accessToken) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
+ }
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
+ }
+ } catch {
+ throw new Error('Unauthorized');
}
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Parse response
+ const rawData = await response.json();
+ const responseData = Pong.unmarshal(rawData);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, optionsPingOptionsRequest),
+ };
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "OPTIONS",
- headers,
- body
- });
+ return result;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+export interface GetMultiStatusResponseContext extends HttpClientContext {
+ requestHeaders?: { marshal: () => string };
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+async function getMultiStatusResponse(context: GetMultiStatusResponseContext = {}): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/ping',
+ server: 'localhost:3000',
+ ...context,
+ };
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Build URL
+ let url = `${config.server}${config.path}`;
+ url = applyQueryParams(config.queryParams, url);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Apply authentication
+ const authResult = applyAuth(config.auth, headers, url);
+ headers = authResult.headers;
+ url = authResult.url;
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "OPTIONS",
- headers,
- body
- });
+ // Prepare body
+ const body = undefined;
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'GET',
+ 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) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
}
- }
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
}
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "OPTIONS",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return Pong.unmarshal(data);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ } catch {
+ throw new Error('Unauthorized');
}
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
}
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
- // Handle common HTTP error codes with standardized messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
+
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
}
- }
-
- const data = await response.json();
- return Pong.unmarshal(data);
-}
-async function getMultiStatusResponse(context: {
- server?: string;
-
- path?: string;
- bearerToken?: string;
- username?: string;
- password?: string;
- apiKey?: string; // API key value
- apiKeyName?: string; // Name of the API key parameter
- apiKeyIn?: 'header' | 'query'; // Where to place the API key (default: header)
- // OAuth2 parameters
- oauth2?: {
- clientId: string;
- clientSecret?: string;
- accessToken?: string;
- refreshToken?: string;
- tokenUrl?: string;
- authorizationUrl?: string;
- redirectUri?: string;
- scopes?: string[];
- flow?: 'authorization_code' | 'implicit' | 'password' | 'client_credentials'; // Added flow parameter
- // For password flow
- username?: string; // Username for password flow
- password?: string; // Password for password flow
- onTokenRefresh?: (newTokens: {
- accessToken: string;
- refreshToken?: string;
- expiresIn?: number;
- }) => void;
- // For Implicit flow
- responseType?: 'token' | 'id_token' | 'id_token token'; // For Implicit flow
- state?: string; // For security against CSRF
- onImplicitRedirect?: (authUrl: string) => void; // Callback for handling the redirect in Implicit flow
+ // Parse response
+ const rawData = await response.json();
+ const responseData = MultiStatusResponseReplyPayloadModule.unmarshalByStatusCode(rawData, response.status);
+
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
+
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, getMultiStatusResponse),
};
- credentials?: RequestCredentials; //value for the credentials param we want to use on each request
- additionalHeaders?: Record; //header params we want to use on every request,
- makeRequestCallback?: ({
- method, body, url, headers
- }: {
- url: string,
- headers?: Record,
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD',
- credentials?: RequestCredentials,
- body?: any
- }) => Promise<{
- ok: boolean,
- status: number,
- statusText: string,
- json: () => Record | Promise>,
- }>
- }): Promise {
- const parsedContext = {
- ...{
- makeRequestCallback: async ({url, body, method, headers}) => {
- return NodeFetch.default(url, {
- body,
- method,
- headers
- })
- },
- path: '/ping',
- server: 'localhost:3000',
- apiKeyIn: 'header',
- apiKeyName: 'X-API-Key',
- },
- ...context,
- }
- // Validate parameters before proceeding with the request
- // OAuth2 Implicit flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit') {
- if (!parsedContext.oauth2.authorizationUrl) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires authorizationUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires clientId'));
- }
- if (!parsedContext.oauth2.redirectUri) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires redirectUri'));
- }
- if (!parsedContext.oauth2.onImplicitRedirect) {
- return Promise.reject(new Error('OAuth2 Implicit flow requires onImplicitRedirect handler'));
- }
- }
+ return result;
- // OAuth2 Client Credentials flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Client Credentials flow requires clientId'));
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // OAuth2 Password flow validation
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password') {
- if (!parsedContext.oauth2.tokenUrl) {
- return Promise.reject(new Error('OAuth2 Password flow requires tokenUrl'));
- }
- if (!parsedContext.oauth2.clientId) {
- return Promise.reject(new Error('OAuth2 Password flow requires clientId'));
- }
- if (!parsedContext.oauth2.username) {
- return Promise.reject(new Error('OAuth2 Password flow requires username'));
- }
- if (!parsedContext.oauth2.password) {
- return Promise.reject(new Error('OAuth2 Password flow requires password'));
- }
- }
+export interface GetGetUserItemContext extends HttpClientContext {
+ parameters: { getChannelWithParameters: (path: string) => string };
+ requestHeaders?: { marshal: () => string };
+}
- const headers = {
- 'Content-Type': 'application/json',
- ...parsedContext.additionalHeaders
+async function getGetUserItem(context: GetGetUserItemContext): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/users/{userId}/items/{itemId}',
+ server: 'localhost:3000',
+ ...context,
};
- let url = `${parsedContext.server}${parsedContext.path}`;
-
- let body: any;
-
-
- // Handle different authentication methods
- if (parsedContext.oauth2 && parsedContext.oauth2.accessToken) {
- // OAuth2 authentication with existing access token
- headers["Authorization"] = `Bearer ${parsedContext.oauth2.accessToken}`;
- } else if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'implicit' && parsedContext.oauth2.authorizationUrl && parsedContext.oauth2.onImplicitRedirect) {
- // Build the authorization URL for implicit flow
- const authUrl = new URL(parsedContext.oauth2.authorizationUrl);
- authUrl.searchParams.append('client_id', parsedContext.oauth2.clientId);
- authUrl.searchParams.append('redirect_uri', parsedContext.oauth2.redirectUri!);
- authUrl.searchParams.append('response_type', parsedContext.oauth2.responseType || 'token');
-
- if (parsedContext.oauth2.state) {
- authUrl.searchParams.append('state', parsedContext.oauth2.state);
- }
-
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- authUrl.searchParams.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
-
- // Call the redirect handler
- parsedContext.oauth2.onImplicitRedirect(authUrl.toString());
- // Since we've initiated a redirect flow, we can't continue with the request
- // The application will need to handle the redirect and subsequent token extraction
- return Promise.reject(new Error('OAuth2 Implicit flow redirect initiated'));
- } else if (parsedContext.bearerToken) {
- // bearer authentication
- headers["Authorization"] = `Bearer ${parsedContext.bearerToken}`;
- } else if (parsedContext.username && parsedContext.password) {
- // basic authentication
- const credentials = Buffer.from(`${parsedContext.username}:${parsedContext.password}`).toString('base64');
- headers["Authorization"] = `Basic ${credentials}`;
- }
-
- // API Key Authentication
- if (parsedContext.apiKey) {
- if (parsedContext.apiKeyIn === 'header') {
- // Add API key to headers
- headers[parsedContext.apiKeyName] = parsedContext.apiKey;
- } else if (parsedContext.apiKeyIn === 'query') {
- // Add API key to query parameters
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${parsedContext.apiKeyName}=${encodeURIComponent(parsedContext.apiKey)}`;
- }
+
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
}
- // Make the API request
- const response = await parsedContext.makeRequestCallback({url,
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
+
+ // Build URL
+ let url = buildUrlWithParameters(config.server, '/users/{userId}/items/{itemId}', context.parameters);
+ 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 = undefined;
+
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
+
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
method: 'GET',
headers,
body
- });
+ };
- // Handle OAuth2 Client Credentials flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'client_credentials' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'client_credentials',
- client_id: parsedContext.oauth2.clientId
- });
+ // Apply beforeRequest hook
+ if (config.hooks?.beforeRequest) {
+ requestParams = await config.hooks.beforeRequest(requestParams);
+ }
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+ 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);
+ }
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
+ // Handle OAuth2 token flows that require getting a token first
+ if (config.auth?.type === 'oauth2' && !config.auth.accessToken) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
+ }
- // Some APIs use basic auth with client credentials instead of form params
- const authHeaders: Record = {
- 'Content-Type': 'application/x-www-form-urlencoded'
- };
-
- // If both client ID and secret are provided, some servers prefer basic auth
- if (parsedContext.oauth2.clientId && parsedContext.oauth2.clientSecret) {
- const credentials = Buffer.from(
- `${parsedContext.oauth2.clientId}:${parsedContext.oauth2.clientSecret}`
- ).toString('base64');
- authHeaders['Authorization'] = `Basic ${credentials}`;
- // Remove client_id and client_secret from the request body when using basic auth
- params.delete('client_id');
- params.delete('client_secret');
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
+ }
+ } catch {
+ throw new Error('Unauthorized');
}
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: authHeaders,
- body: params.toString()
- });
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
+ }
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Parse response
+ const rawData = await response.json();
+ const responseData = GetUserItemReplyPayloadModule.unmarshalByStatusCode(rawData, response.status);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, getGetUserItem),
+ };
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
+ return result;
- const data = await retryResponse.json();
- return MultiStatusResponseReplyPayloadModule.unmarshalByStatusCode(data, retryResponse.status);
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
- }
- } catch (error) {
- console.error('Error in OAuth2 Client Credentials flow:', error);
- return Promise.reject(error);
+ } catch (error) {
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
+}
- // Handle OAuth2 password flow
- if (parsedContext.oauth2 && parsedContext.oauth2.flow === 'password' && parsedContext.oauth2.tokenUrl) {
- try {
- const params = new URLSearchParams({
- grant_type: 'password',
- username: parsedContext.oauth2.username || '',
- password: parsedContext.oauth2.password || '',
- client_id: parsedContext.oauth2.clientId,
- });
+export interface PutUpdateUserItemContext extends HttpClientContext {
+ payload: ItemRequest;
+ parameters: { getChannelWithParameters: (path: string) => string };
+ requestHeaders?: { marshal: () => string };
+}
- if (parsedContext.oauth2.clientSecret) {
- params.append('client_secret', parsedContext.oauth2.clientSecret);
- }
+async function putUpdateUserItem(context: PutUpdateUserItemContext): Promise> {
+ // Apply defaults
+ const config = {
+ path: '/users/{userId}/items/{itemId}',
+ server: 'localhost:3000',
+ ...context,
+ };
- if (parsedContext.oauth2.scopes && parsedContext.oauth2.scopes.length > 0) {
- params.append('scope', parsedContext.oauth2.scopes.join(' '));
- }
+ // Validate OAuth2 config if present
+ if (config.auth?.type === 'oauth2') {
+ validateOAuth2Config(config.auth);
+ }
- const tokenResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: params.toString()
- });
+ // Build headers
+ let headers = context.requestHeaders
+ ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders)
+ : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record;
- if (tokenResponse.ok) {
- const tokenData = await tokenResponse.json();
- const tokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token,
- expiresIn: tokenData.expires_in
- };
+ // Build URL
+ let url = buildUrlWithParameters(config.server, '/users/{userId}/items/{itemId}', context.parameters);
+ url = applyQueryParams(config.queryParams, url);
- // Update headers with the new token
- headers["Authorization"] = `Bearer ${tokens.accessToken}`;
+ // Apply pagination (can affect URL and/or headers)
+ const paginationResult = applyPagination(config.pagination, url, headers);
+ url = paginationResult.url;
+ headers = paginationResult.headers;
- // Notify the client about the tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(tokens);
- }
+ // Apply authentication
+ const authResult = applyAuth(config.auth, headers, url);
+ headers = authResult.headers;
+ url = authResult.url;
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
+ // Prepare body
+ const body = context.payload?.marshal();
- const data = await retryResponse.json();
- return MultiStatusResponseReplyPayloadModule.unmarshalByStatusCode(data, retryResponse.status);
+ // Determine request function
+ const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest;
- } else {
- return Promise.reject(new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`));
+ // Build request params
+ let requestParams: HttpRequestParams = {
+ url,
+ method: 'PUT',
+ 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) {
+ const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry);
+ if (tokenFlowResponse) {
+ response = tokenFlowResponse;
}
- } catch (error) {
- console.error('Error in OAuth2 password flow:', error);
- return Promise.reject(error);
}
- }
- // Handle token refresh for OAuth2 if we get a 401
- if (response.status === 401 && parsedContext.oauth2 && parsedContext.oauth2.refreshToken && parsedContext.oauth2.tokenUrl && parsedContext.oauth2.clientId) {
- try {
- const refreshResponse = await NodeFetch.default(parsedContext.oauth2.tokenUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: parsedContext.oauth2.refreshToken,
- client_id: parsedContext.oauth2.clientId,
- ...(parsedContext.oauth2.clientSecret ? { client_secret: parsedContext.oauth2.clientSecret } : {})
- }).toString()
- });
-
- if (refreshResponse.ok) {
- const tokenData = await refreshResponse.json();
- const newTokens = {
- accessToken: tokenData.access_token,
- refreshToken: tokenData.refresh_token || parsedContext.oauth2.refreshToken,
- expiresIn: tokenData.expires_in
- };
-
- // Update the access token for this request
- headers["Authorization"] = `Bearer ${newTokens.accessToken}`;
-
- // Notify the client about the refreshed tokens
- if (parsedContext.oauth2.onTokenRefresh) {
- parsedContext.oauth2.onTokenRefresh(newTokens);
+ // Handle 401 with token refresh
+ if (response.status === 401 && config.auth?.type === 'oauth2') {
+ try {
+ const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry);
+ if (refreshResponse) {
+ response = refreshResponse;
}
-
- // Retry the original request with the new token
- const retryResponse = await parsedContext.makeRequestCallback({
- url,
- method: "GET",
- headers,
- body
- });
-
- const data = await retryResponse.json();
- return MultiStatusResponseReplyPayloadModule.unmarshalByStatusCode(data, retryResponse.status);
- } else {
- // Token refresh failed, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
+ } catch {
+ throw new Error('Unauthorized');
}
- } catch (error) {
- console.error('Error refreshing token:', error);
- // For any error during refresh, return a standardized error message
- return Promise.reject(new Error('Unauthorized'));
}
- }
-
- // Handle error status codes before attempting to parse JSON
- if (!response.ok) {
- // For multi-status responses (with replyMessageModule), let unmarshalByStatusCode handle the parsing
- // Only throw standardized errors for simple responses or when JSON parsing fails
-
- }
-
- // For multi-status responses, always try to parse JSON and let unmarshalByStatusCode handle it
- try {
- const data = await response.json();
- return MultiStatusResponseReplyPayloadModule.unmarshalByStatusCode(data, response.status);
+
+ // Handle error responses
+ if (!response.ok) {
+ handleHttpError(response.status, response.statusText);
+ }
+
+ // Parse response
+ const rawData = await response.json();
+ const responseData = UpdateUserItemReplyPayloadModule.unmarshalByStatusCode(rawData, response.status);
+
+ // Extract response metadata
+ const responseHeaders = extractHeaders(response);
+ const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination);
+
+ // Build response wrapper with pagination helpers
+ const result: HttpClientResponse = {
+ data: responseData,
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ rawData,
+ pagination: paginationInfo,
+ ...createPaginationHelpers(config, paginationInfo, putUpdateUserItem),
+ };
+
+ return result;
+
} catch (error) {
- // If JSON parsing fails or unmarshalByStatusCode fails, provide standardized error messages
- if (response.status === 401) {
- return Promise.reject(new Error('Unauthorized'));
- } else if (response.status === 403) {
- return Promise.reject(new Error('Forbidden'));
- } else if (response.status === 404) {
- return Promise.reject(new Error('Not Found'));
- } else if (response.status === 500) {
- return Promise.reject(new Error('Internal Server Error'));
- } else {
- return Promise.reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
+ // Apply onError hook if present
+ if (config.hooks?.onError && error instanceof Error) {
+ throw await config.hooks.onError(error, requestParams);
}
+ throw error;
}
}
-export { postPingPostRequest, getPingGetRequest, putPingPutRequest, deletePingDeleteRequest, patchPingPatchRequest, headPingHeadRequest, optionsPingOptionsRequest, getMultiStatusResponse };
+export { postPingPostRequest, getPingGetRequest, putPingPutRequest, deletePingDeleteRequest, patchPingPatchRequest, headPingHeadRequest, optionsPingOptionsRequest, getMultiStatusResponse, getGetUserItem, putUpdateUserItem };
diff --git a/test/runtime/typescript/src/request-reply/channels/nats.ts b/test/runtime/typescript/src/request-reply/channels/nats.ts
index 15d67c86..6f85d9d1 100644
--- a/test/runtime/typescript/src/request-reply/channels/nats.ts
+++ b/test/runtime/typescript/src/request-reply/channels/nats.ts
@@ -1,8 +1,15 @@
import {Pong} from './../payloads/Pong';
import {Ping} from './../payloads/Ping';
import * as MultiStatusResponseReplyPayloadModule from './../payloads/MultiStatusResponseReplyPayload';
+import * as GetUserItemReplyPayloadModule from './../payloads/GetUserItemReplyPayload';
+import * as UpdateUserItemReplyPayloadModule from './../payloads/UpdateUserItemReplyPayload';
+import {ItemRequest} from './../payloads/ItemRequest';
import * as PingPayloadModule from './../payloads/PingPayload';
+import * as UserItemsPayloadModule from './../payloads/UserItemsPayload';
import {NotFound} from './../payloads/NotFound';
+import {ItemResponse} from './../payloads/ItemResponse';
+import {UserItemsParameters} from './../parameters/UserItemsParameters';
+import {ItemRequestHeaders} from './../headers/ItemRequestHeaders';
import * as Nats from 'nats';
/**
diff --git a/test/runtime/typescript/src/request-reply/client/NatsClient.ts b/test/runtime/typescript/src/request-reply/client/NatsClient.ts
index 266eb781..403051bd 100644
--- a/test/runtime/typescript/src/request-reply/client/NatsClient.ts
+++ b/test/runtime/typescript/src/request-reply/client/NatsClient.ts
@@ -1,13 +1,25 @@
import {Pong} from './../payloads/Pong';
import {Ping} from './../payloads/Ping';
import * as MultiStatusResponseReplyPayloadModule from './../payloads/MultiStatusResponseReplyPayload';
+import * as GetUserItemReplyPayloadModule from './../payloads/GetUserItemReplyPayload';
+import * as UpdateUserItemReplyPayloadModule from './../payloads/UpdateUserItemReplyPayload';
+import {ItemRequest} from './../payloads/ItemRequest';
import * as PingPayloadModule from './../payloads/PingPayload';
+import * as UserItemsPayloadModule from './../payloads/UserItemsPayload';
import {NotFound} from './../payloads/NotFound';
+import {ItemResponse} from './../payloads/ItemResponse';
export {Pong};
export {Ping};
export {MultiStatusResponseReplyPayloadModule};
+export {GetUserItemReplyPayloadModule};
+export {UpdateUserItemReplyPayloadModule};
+export {ItemRequest};
export {PingPayloadModule};
+export {UserItemsPayloadModule};
export {NotFound};
+export {ItemResponse};
+import {UserItemsParameters} from './../parameters/UserItemsParameters';
+export {UserItemsParameters};
//Import channel functions
import * as nats from './../channels/nats';
diff --git a/test/runtime/typescript/src/request-reply/headers/ItemRequestHeaders.ts b/test/runtime/typescript/src/request-reply/headers/ItemRequestHeaders.ts
new file mode 100644
index 00000000..b4320a4d
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/headers/ItemRequestHeaders.ts
@@ -0,0 +1,89 @@
+import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
+import addFormats from 'ajv-formats';
+class ItemRequestHeaders {
+ private _xCorrelationId: string;
+ private _xRequestId?: string;
+ private _additionalProperties?: Map;
+
+ constructor(input: {
+ xCorrelationId: string,
+ xRequestId?: string,
+ additionalProperties?: Map,
+ }) {
+ this._xCorrelationId = input.xCorrelationId;
+ this._xRequestId = input.xRequestId;
+ this._additionalProperties = input.additionalProperties;
+ }
+
+ /**
+ * Correlation ID for request tracing
+ */
+ get xCorrelationId(): string { return this._xCorrelationId; }
+ set xCorrelationId(xCorrelationId: string) { this._xCorrelationId = xCorrelationId; }
+
+ /**
+ * Unique request identifier
+ */
+ get xRequestId(): string | undefined { return this._xRequestId; }
+ set xRequestId(xRequestId: string | undefined) { this._xRequestId = xRequestId; }
+
+ get additionalProperties(): Map | undefined { return this._additionalProperties; }
+ set additionalProperties(additionalProperties: Map | undefined) { this._additionalProperties = additionalProperties; }
+
+ public marshal() : string {
+ let json = '{'
+ if(this.xCorrelationId !== undefined) {
+ json += `"x-correlation-id": ${typeof this.xCorrelationId === 'number' || typeof this.xCorrelationId === 'boolean' ? this.xCorrelationId : JSON.stringify(this.xCorrelationId)},`;
+ }
+ if(this.xRequestId !== undefined) {
+ json += `"x-request-id": ${typeof this.xRequestId === 'number' || typeof this.xRequestId === 'boolean' ? this.xRequestId : JSON.stringify(this.xRequestId)},`;
+ }
+ if(this.additionalProperties !== undefined) {
+ for (const [key, value] of this.additionalProperties.entries()) {
+ //Only unwrap those that are not already a property in the JSON object
+ if(["x-correlation-id","x-request-id","additionalProperties"].includes(String(key))) continue;
+ json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`;
+ }
+ }
+ //Remove potential last comma
+ return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`;
+ }
+
+ public static unmarshal(json: string | object): ItemRequestHeaders {
+ const obj = typeof json === "object" ? json : JSON.parse(json);
+ const instance = new ItemRequestHeaders({} as any);
+
+ if (obj["x-correlation-id"] !== undefined) {
+ instance.xCorrelationId = obj["x-correlation-id"];
+ }
+ if (obj["x-request-id"] !== undefined) {
+ instance.xRequestId = obj["x-request-id"];
+ }
+
+ instance.additionalProperties = new Map();
+ const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["x-correlation-id","x-request-id","additionalProperties"].includes(key);}));
+ for (const [key, value] of propsToCheck) {
+ instance.additionalProperties.set(key, value as any);
+ }
+ return instance;
+ }
+ public static theCodeGenSchema = {"type":"object","properties":{"x-correlation-id":{"type":"string","description":"Correlation ID for request tracing"},"x-request-id":{"type":"string","description":"Unique request identifier"}},"required":["x-correlation-id"],"$id":"ItemRequestHeaders","$schema":"http://json-schema.org/draft-07/schema"};
+ public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
+ const {data, ajvValidatorFunction} = context ?? {};
+ const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
+ const validate = ajvValidatorFunction ?? this.createValidator(context)
+ return {
+ valid: validate(parsedData),
+ errors: validate.errors ?? undefined,
+ };
+ }
+ public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction {
+ const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})};
+ addFormats(ajvInstance);
+
+ const validate = ajvInstance.compile(this.theCodeGenSchema);
+ return validate;
+ }
+
+}
+export { ItemRequestHeaders };
\ No newline at end of file
diff --git a/test/runtime/typescript/src/request-reply/parameters/UserItemsParameters.ts b/test/runtime/typescript/src/request-reply/parameters/UserItemsParameters.ts
new file mode 100644
index 00000000..3c8656f8
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/parameters/UserItemsParameters.ts
@@ -0,0 +1,60 @@
+
+class UserItemsParameters {
+ private _userId: string;
+ private _itemId: string;
+
+ constructor(input: {
+ userId: string,
+ itemId: string,
+ }) {
+ this._userId = input.userId;
+ this._itemId = input.itemId;
+ }
+
+ /**
+ * The unique identifier of the user
+ */
+ get userId(): string { return this._userId; }
+ set userId(userId: string) { this._userId = userId; }
+
+ /**
+ * The unique identifier of the item
+ */
+ get itemId(): string { return this._itemId; }
+ set itemId(itemId: string) { this._itemId = itemId; }
+
+
+ /**
+ * Realize the channel/topic with the parameters added to this class.
+ */
+ public getChannelWithParameters(channel: string) {
+ channel = channel.replace(/\{userId\}/g, this.userId);
+ channel = channel.replace(/\{itemId\}/g, this.itemId);
+ return channel;
+ }
+
+ public static createFromChannel(msgSubject: string, channel: string, regex: RegExp): UserItemsParameters {
+ const parameters = new UserItemsParameters({userId: '', itemId: ''});
+ const match = msgSubject.match(regex);
+ const sequentialParameters: string[] = channel.match(/\{(\w+)\}/g) || [];
+
+ if (match) {
+ const userIdMatch = match[sequentialParameters.indexOf('{userId}')+1];
+ if(userIdMatch && userIdMatch !== '') {
+ parameters.userId = userIdMatch as any
+ } else {
+ throw new Error(`Parameter: 'userId' is not valid in UserItemsParameters. Aborting parameter extracting! `)
+ }
+ const itemIdMatch = match[sequentialParameters.indexOf('{itemId}')+1];
+ if(itemIdMatch && itemIdMatch !== '') {
+ parameters.itemId = itemIdMatch as any
+ } else {
+ throw new Error(`Parameter: 'itemId' is not valid in UserItemsParameters. Aborting parameter extracting! `)
+ }
+ } else {
+ throw new Error(`Unable to find parameters in channel/topic, topic was ${channel}`)
+ }
+ return parameters;
+ }
+}
+export { UserItemsParameters };
\ No newline at end of file
diff --git a/test/runtime/typescript/src/request-reply/payloads/GetUserItemReplyPayload.ts b/test/runtime/typescript/src/request-reply/payloads/GetUserItemReplyPayload.ts
new file mode 100644
index 00000000..8c3d5058
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/payloads/GetUserItemReplyPayload.ts
@@ -0,0 +1,49 @@
+import {ItemResponse} from './ItemResponse';
+import {NotFound} from './NotFound';
+import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
+import addFormats from 'ajv-formats';
+type GetUserItemReplyPayload = ItemResponse | NotFound;
+
+export function unmarshal(json: any): GetUserItemReplyPayload {
+
+ return JSON.parse(json);
+}
+export function marshal(payload: GetUserItemReplyPayload) {
+ if(payload instanceof ItemResponse) {
+return payload.marshal();
+}
+if(payload instanceof NotFound) {
+return payload.marshal();
+}
+ return JSON.stringify(payload);
+}
+
+export function unmarshalByStatusCode(json: any, statusCode: number): GetUserItemReplyPayload {
+ if (statusCode === 200) {
+ return ItemResponse.unmarshal(json);
+ }
+ if (statusCode === 404) {
+ return NotFound.unmarshal(json);
+ }
+ throw new Error(`No matching type found for status code: ${statusCode}`);
+}
+export const theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"object","properties":{"id":{"type":"string","description":"The item ID"},"userId":{"type":"string","description":"Owner user ID"},"name":{"type":"string","description":"Name of the item"},"description":{"type":"string","description":"Item description"},"quantity":{"type":"integer","description":"Item quantity"}},"$id":"itemResponse"},{"type":"object","properties":{"error":{"type":"string","description":"Error message"},"code":{"type":"string","description":"Error code"}},"$id":"notFound"}],"$id":"GetUserItemReplyPayload"};
+export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
+ const {data, ajvValidatorFunction} = context ?? {};
+ const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
+ const validate = ajvValidatorFunction ?? createValidator(context)
+ return {
+ valid: validate(parsedData),
+ errors: validate.errors ?? undefined,
+ };
+}
+export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction {
+ const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})};
+ addFormats(ajvInstance);
+
+ const validate = ajvInstance.compile(theCodeGenSchema);
+ return validate;
+}
+
+
+export { GetUserItemReplyPayload };
\ No newline at end of file
diff --git a/test/runtime/typescript/src/request-reply/payloads/ItemRequest.ts b/test/runtime/typescript/src/request-reply/payloads/ItemRequest.ts
new file mode 100644
index 00000000..c1704ecc
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/payloads/ItemRequest.ts
@@ -0,0 +1,95 @@
+import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
+import addFormats from 'ajv-formats';
+class ItemRequest {
+ private _name: string;
+ private _description?: string;
+ private _quantity?: number;
+ private _additionalProperties?: Record;
+
+ constructor(input: {
+ name: string,
+ description?: string,
+ quantity?: number,
+ additionalProperties?: Record,
+ }) {
+ this._name = input.name;
+ this._description = input.description;
+ this._quantity = input.quantity;
+ this._additionalProperties = input.additionalProperties;
+ }
+
+ get name(): string { return this._name; }
+ set name(name: string) { this._name = name; }
+
+ get description(): string | undefined { return this._description; }
+ set description(description: string | undefined) { this._description = description; }
+
+ get quantity(): number | undefined { return this._quantity; }
+ set quantity(quantity: number | undefined) { this._quantity = quantity; }
+
+ get additionalProperties(): Record | undefined { return this._additionalProperties; }
+ set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; }
+
+ public marshal() : string {
+ let json = '{'
+ if(this.name !== undefined) {
+ json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`;
+ }
+ if(this.description !== undefined) {
+ json += `"description": ${typeof this.description === 'number' || typeof this.description === 'boolean' ? this.description : JSON.stringify(this.description)},`;
+ }
+ if(this.quantity !== undefined) {
+ json += `"quantity": ${typeof this.quantity === 'number' || typeof this.quantity === 'boolean' ? this.quantity : JSON.stringify(this.quantity)},`;
+ }
+ if(this.additionalProperties !== undefined) {
+ for (const [key, value] of this.additionalProperties.entries()) {
+ //Only unwrap those that are not already a property in the JSON object
+ if(["name","description","quantity","additionalProperties"].includes(String(key))) continue;
+ json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`;
+ }
+ }
+ //Remove potential last comma
+ return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`;
+ }
+
+ public static unmarshal(json: string | object): ItemRequest {
+ const obj = typeof json === "object" ? json : JSON.parse(json);
+ const instance = new ItemRequest({} as any);
+
+ if (obj["name"] !== undefined) {
+ instance.name = obj["name"];
+ }
+ if (obj["description"] !== undefined) {
+ instance.description = obj["description"];
+ }
+ if (obj["quantity"] !== undefined) {
+ instance.quantity = obj["quantity"];
+ }
+
+ instance.additionalProperties = new Map();
+ const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["name","description","quantity","additionalProperties"].includes(key);}));
+ for (const [key, value] of propsToCheck) {
+ instance.additionalProperties.set(key, value as any);
+ }
+ return instance;
+ }
+ public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","properties":{"name":{"type":"string","description":"Name of the item"},"description":{"type":"string","description":"Item description"},"quantity":{"type":"integer","description":"Item quantity"}},"required":["name"],"$id":"itemRequest"};
+ public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
+ const {data, ajvValidatorFunction} = context ?? {};
+ const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
+ const validate = ajvValidatorFunction ?? this.createValidator(context)
+ return {
+ valid: validate(parsedData),
+ errors: validate.errors ?? undefined,
+ };
+ }
+ public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction {
+ const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})};
+ addFormats(ajvInstance);
+
+ const validate = ajvInstance.compile(this.theCodeGenSchema);
+ return validate;
+ }
+
+}
+export { ItemRequest };
\ No newline at end of file
diff --git a/test/runtime/typescript/src/request-reply/payloads/ItemResponse.ts b/test/runtime/typescript/src/request-reply/payloads/ItemResponse.ts
new file mode 100644
index 00000000..b4b83f0c
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/payloads/ItemResponse.ts
@@ -0,0 +1,119 @@
+import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
+import addFormats from 'ajv-formats';
+class ItemResponse {
+ private _id?: string;
+ private _userId?: string;
+ private _name?: string;
+ private _description?: string;
+ private _quantity?: number;
+ private _additionalProperties?: Record;
+
+ constructor(input: {
+ id?: string,
+ userId?: string,
+ name?: string,
+ description?: string,
+ quantity?: number,
+ additionalProperties?: Record,
+ }) {
+ this._id = input.id;
+ this._userId = input.userId;
+ this._name = input.name;
+ this._description = input.description;
+ this._quantity = input.quantity;
+ this._additionalProperties = input.additionalProperties;
+ }
+
+ get id(): string | undefined { return this._id; }
+ set id(id: string | undefined) { this._id = id; }
+
+ get userId(): string | undefined { return this._userId; }
+ set userId(userId: string | undefined) { this._userId = userId; }
+
+ get name(): string | undefined { return this._name; }
+ set name(name: string | undefined) { this._name = name; }
+
+ get description(): string | undefined { return this._description; }
+ set description(description: string | undefined) { this._description = description; }
+
+ get quantity(): number | undefined { return this._quantity; }
+ set quantity(quantity: number | undefined) { this._quantity = quantity; }
+
+ get additionalProperties(): Record | undefined { return this._additionalProperties; }
+ set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; }
+
+ public marshal() : string {
+ let json = '{'
+ if(this.id !== undefined) {
+ json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`;
+ }
+ if(this.userId !== undefined) {
+ json += `"userId": ${typeof this.userId === 'number' || typeof this.userId === 'boolean' ? this.userId : JSON.stringify(this.userId)},`;
+ }
+ if(this.name !== undefined) {
+ json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`;
+ }
+ if(this.description !== undefined) {
+ json += `"description": ${typeof this.description === 'number' || typeof this.description === 'boolean' ? this.description : JSON.stringify(this.description)},`;
+ }
+ if(this.quantity !== undefined) {
+ json += `"quantity": ${typeof this.quantity === 'number' || typeof this.quantity === 'boolean' ? this.quantity : JSON.stringify(this.quantity)},`;
+ }
+ if(this.additionalProperties !== undefined) {
+ for (const [key, value] of this.additionalProperties.entries()) {
+ //Only unwrap those that are not already a property in the JSON object
+ if(["id","userId","name","description","quantity","additionalProperties"].includes(String(key))) continue;
+ json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`;
+ }
+ }
+ //Remove potential last comma
+ return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`;
+ }
+
+ public static unmarshal(json: string | object): ItemResponse {
+ const obj = typeof json === "object" ? json : JSON.parse(json);
+ const instance = new ItemResponse({} as any);
+
+ if (obj["id"] !== undefined) {
+ instance.id = obj["id"];
+ }
+ if (obj["userId"] !== undefined) {
+ instance.userId = obj["userId"];
+ }
+ if (obj["name"] !== undefined) {
+ instance.name = obj["name"];
+ }
+ if (obj["description"] !== undefined) {
+ instance.description = obj["description"];
+ }
+ if (obj["quantity"] !== undefined) {
+ instance.quantity = obj["quantity"];
+ }
+
+ instance.additionalProperties = new Map();
+ const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","userId","name","description","quantity","additionalProperties"].includes(key);}));
+ for (const [key, value] of propsToCheck) {
+ instance.additionalProperties.set(key, value as any);
+ }
+ return instance;
+ }
+ public static theCodeGenSchema = {"type":"object","properties":{"id":{"type":"string","description":"The item ID"},"userId":{"type":"string","description":"Owner user ID"},"name":{"type":"string","description":"Name of the item"},"description":{"type":"string","description":"Item description"},"quantity":{"type":"integer","description":"Item quantity"}},"$id":"itemResponse"};
+ public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
+ const {data, ajvValidatorFunction} = context ?? {};
+ const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
+ const validate = ajvValidatorFunction ?? this.createValidator(context)
+ return {
+ valid: validate(parsedData),
+ errors: validate.errors ?? undefined,
+ };
+ }
+ public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction {
+ const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})};
+ addFormats(ajvInstance);
+
+ const validate = ajvInstance.compile(this.theCodeGenSchema);
+ return validate;
+ }
+
+}
+export { ItemResponse };
\ No newline at end of file
diff --git a/test/runtime/typescript/src/request-reply/payloads/UpdateUserItemReplyPayload.ts b/test/runtime/typescript/src/request-reply/payloads/UpdateUserItemReplyPayload.ts
new file mode 100644
index 00000000..c2faf9b9
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/payloads/UpdateUserItemReplyPayload.ts
@@ -0,0 +1,49 @@
+import {ItemResponse} from './ItemResponse';
+import {NotFound} from './NotFound';
+import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
+import addFormats from 'ajv-formats';
+type UpdateUserItemReplyPayload = ItemResponse | NotFound;
+
+export function unmarshal(json: any): UpdateUserItemReplyPayload {
+
+ return JSON.parse(json);
+}
+export function marshal(payload: UpdateUserItemReplyPayload) {
+ if(payload instanceof ItemResponse) {
+return payload.marshal();
+}
+if(payload instanceof NotFound) {
+return payload.marshal();
+}
+ return JSON.stringify(payload);
+}
+
+export function unmarshalByStatusCode(json: any, statusCode: number): UpdateUserItemReplyPayload {
+ if (statusCode === 200) {
+ return ItemResponse.unmarshal(json);
+ }
+ if (statusCode === 404) {
+ return NotFound.unmarshal(json);
+ }
+ throw new Error(`No matching type found for status code: ${statusCode}`);
+}
+export const theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"object","properties":{"id":{"type":"string","description":"The item ID"},"userId":{"type":"string","description":"Owner user ID"},"name":{"type":"string","description":"Name of the item"},"description":{"type":"string","description":"Item description"},"quantity":{"type":"integer","description":"Item quantity"}},"$id":"itemResponse"},{"type":"object","properties":{"error":{"type":"string","description":"Error message"},"code":{"type":"string","description":"Error code"}},"$id":"notFound"}],"$id":"UpdateUserItemReplyPayload"};
+export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
+ const {data, ajvValidatorFunction} = context ?? {};
+ const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
+ const validate = ajvValidatorFunction ?? createValidator(context)
+ return {
+ valid: validate(parsedData),
+ errors: validate.errors ?? undefined,
+ };
+}
+export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction {
+ const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})};
+ addFormats(ajvInstance);
+
+ const validate = ajvInstance.compile(theCodeGenSchema);
+ return validate;
+}
+
+
+export { UpdateUserItemReplyPayload };
\ No newline at end of file
diff --git a/test/runtime/typescript/src/request-reply/payloads/UserItemsPayload.ts b/test/runtime/typescript/src/request-reply/payloads/UserItemsPayload.ts
new file mode 100644
index 00000000..cdb93c5d
--- /dev/null
+++ b/test/runtime/typescript/src/request-reply/payloads/UserItemsPayload.ts
@@ -0,0 +1,53 @@
+import {ItemRequest} from './ItemRequest';
+import {ItemResponse} from './ItemResponse';
+import {NotFound} from './NotFound';
+import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
+import addFormats from 'ajv-formats';
+type UserItemsPayload = ItemRequest | ItemResponse | NotFound;
+
+export function unmarshal(json: any): UserItemsPayload {
+
+ return JSON.parse(json);
+}
+export function marshal(payload: UserItemsPayload) {
+ if(payload instanceof ItemRequest) {
+return payload.marshal();
+}
+if(payload instanceof ItemResponse) {
+return payload.marshal();
+}
+if(payload instanceof NotFound) {
+return payload.marshal();
+}
+ return JSON.stringify(payload);
+}
+
+export function unmarshalByStatusCode(json: any, statusCode: number): UserItemsPayload {
+ if (statusCode === 200) {
+ return ItemResponse.unmarshal(json);
+ }
+ if (statusCode === 404) {
+ return NotFound.unmarshal(json);
+ }
+ throw new Error(`No matching type found for status code: ${statusCode}`);
+}
+export const theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"object","properties":{"name":{"type":"string","description":"Name of the item"},"description":{"type":"string","description":"Item description"},"quantity":{"type":"integer","description":"Item quantity"}},"required":["name"],"$id":"itemRequest"},{"type":"object","properties":{"id":{"type":"string","description":"The item ID"},"userId":{"type":"string","description":"Owner user ID"},"name":{"type":"string","description":"Name of the item"},"description":{"type":"string","description":"Item description"},"quantity":{"type":"integer","description":"Item quantity"}},"$id":"itemResponse"},{"type":"object","properties":{"error":{"type":"string","description":"Error message"},"code":{"type":"string","description":"Error code"}},"$id":"notFound"}],"$id":"UserItemsPayload"};
+export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
+ const {data, ajvValidatorFunction} = context ?? {};
+ const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
+ const validate = ajvValidatorFunction ?? createValidator(context)
+ return {
+ valid: validate(parsedData),
+ errors: validate.errors ?? undefined,
+ };
+}
+export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction {
+ const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})};
+ addFormats(ajvInstance);
+
+ const validate = ajvInstance.compile(theCodeGenSchema);
+ return validate;
+}
+
+
+export { UserItemsPayload };
\ No newline at end of file
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 41e8d38e..c69cd964 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
@@ -9,7 +9,7 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
describe('Authentication Methods', () => {
it('should authenticate with API Key in header', async () => {
const { app, router, port } = createTestServer();
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const API_KEY = 'test-api-key-12345';
@@ -20,28 +20,31 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
if (req.headers[API_KEY_NAME.toLowerCase()] !== API_KEY) {
return res.status(401).json({ error: 'Unauthorized - Invalid API Key' });
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- apiKey: API_KEY,
- apiKeyName: API_KEY_NAME,
- apiKeyIn: 'header'
+ auth: {
+ type: 'apiKey',
+ key: API_KEY,
+ name: API_KEY_NAME,
+ in: 'header'
+ }
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should authenticate with API Key in query parameter', async () => {
const { app, router, port } = createTestServer();
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const API_KEY = 'test-api-key-12345';
@@ -52,28 +55,31 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
if (req.query[API_KEY_NAME] !== API_KEY) {
return res.status(401).json({ error: 'Unauthorized - Invalid API Key' });
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- apiKey: API_KEY,
- apiKeyName: API_KEY_NAME,
- apiKeyIn: 'query'
+ auth: {
+ type: 'apiKey',
+ key: API_KEY,
+ name: API_KEY_NAME,
+ in: 'query'
+ }
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should authenticate with Basic Authentication', async () => {
const { app, router, port } = createTestServer();
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const USERNAME = 'testuser';
@@ -85,36 +91,39 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({ error: 'Unauthorized - Basic Authentication Required' });
}
-
+
// Decode and validate the Basic auth credentials
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
-
+
if (username !== USERNAME || password !== PASSWORD) {
return res.status(401).json({ error: 'Unauthorized - Invalid Credentials' });
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- username: USERNAME,
- password: PASSWORD
+ auth: {
+ type: 'basic',
+ username: USERNAME,
+ password: PASSWORD
+ }
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should authenticate with Bearer Token', async () => {
const { app, router, port } = createTestServer();
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const BEARER_TOKEN = 'jwt-token-12345';
@@ -125,32 +134,35 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized - Bearer Authentication Required' });
}
-
+
// Validate the Bearer token
const token = authHeader.split(' ')[1];
if (token !== BEARER_TOKEN) {
return res.status(401).json({ error: 'Unauthorized - Invalid Token' });
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- bearerToken: BEARER_TOKEN
+ auth: {
+ type: 'bearer',
+ token: BEARER_TOKEN
+ }
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should handle unauthorized errors with API Key', async () => {
const { app, router, port } = createTestServer();
-
+
const requestMessage = new Ping({});
const API_KEY_NAME = 'X-API-Key';
@@ -159,15 +171,18 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
res.status(401);
res.end();
});
-
+
return runWithServer(app, port, async () => {
try {
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- apiKey: 'wrong-api-key',
- apiKeyName: API_KEY_NAME,
- apiKeyIn: 'header'
+ auth: {
+ type: 'apiKey',
+ key: 'wrong-api-key',
+ name: API_KEY_NAME,
+ in: 'header'
+ }
});
throw new Error('Expected request to fail with 401 status');
} catch (error) {
@@ -176,4 +191,4 @@ describe('HTTP Client - API Key and Basic Authentication', () => {
});
});
});
-});
\ No newline at end of file
+});
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
new file mode 100644
index 00000000..65a55529
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/authentication.spec.ts
@@ -0,0 +1,252 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ getPingGetRequest,
+ AuthConfig,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - Authentication', () => {
+ describe('bearer token', () => {
+ it('should send bearer token in Authorization header', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: AuthConfig = { type: 'bearer', token: 'test-token-123' };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer test-token-123');
+ });
+ });
+ });
+
+ describe('basic auth', () => {
+ it('should send basic auth credentials', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: AuthConfig = { type: 'basic', username: 'user', password: 'pass' };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ const expectedCredentials = Buffer.from('user:pass').toString('base64');
+ expect(receivedAuthHeader).toBe(`Basic ${expectedCredentials}`);
+ });
+ });
+ });
+
+ describe('API key', () => {
+ it('should send API key in header', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedApiKey: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedApiKey = req.headers['x-api-key'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: AuthConfig = { type: 'apiKey', key: 'my-api-key-123' };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(receivedApiKey).toBe('my-api-key-123');
+ });
+ });
+
+ it('should send API key in query string', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedApiKey: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedApiKey = req.query['api_key'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: AuthConfig = {
+ type: 'apiKey',
+ key: 'my-api-key-123',
+ name: 'api_key',
+ in: 'query'
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(receivedApiKey).toBe('my-api-key-123');
+ });
+ });
+
+ it('should use custom API key header name', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedApiKey: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedApiKey = req.headers['x-custom-auth'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: AuthConfig = {
+ type: 'apiKey',
+ key: 'custom-key-value',
+ name: 'X-Custom-Auth',
+ in: 'header'
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(receivedApiKey).toBe('custom-key-value');
+ });
+ });
+
+ it('should handle API key in query string with existing query params', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedApiKey: string | undefined;
+ let receivedFilter: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedApiKey = req.query['api_key'] as string;
+ receivedFilter = req.query['filter'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth: {
+ type: 'apiKey',
+ key: 'secret-key',
+ name: 'api_key',
+ in: 'query'
+ },
+ queryParams: {
+ filter: 'active'
+ }
+ });
+
+ expect(receivedApiKey).toBe('secret-key');
+ expect(receivedFilter).toBe('active');
+ });
+ });
+ });
+
+ describe('OAuth2 access token', () => {
+ it('should use OAuth2 access token', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: AuthConfig = {
+ type: 'oauth2',
+ accessToken: 'oauth-access-token-xyz'
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer oauth-access-token-xyz');
+ });
+ });
+ });
+
+ describe('combined with additional headers', () => {
+ it('should combine auth with additional headers', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+ let receivedCustomHeader: string | undefined;
+ let receivedTraceId: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ receivedCustomHeader = req.headers['x-custom-header'] as string;
+ receivedTraceId = req.headers['x-trace-id'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth: { type: 'bearer', token: 'my-token' },
+ additionalHeaders: {
+ 'X-Custom-Header': 'custom-value',
+ 'X-Trace-Id': 'trace-123'
+ }
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer my-token');
+ expect(receivedCustomHeader).toBe('custom-value');
+ expect(receivedTraceId).toBe('trace-123');
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000..f6b876d6
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/basics.spec.ts
@@ -0,0 +1,159 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { Ping } from "../../../../src/request-reply/payloads/Ping";
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ postPingPostRequest,
+ getPingGetRequest,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - Basics', () => {
+ describe('response wrapper', () => {
+ it('should return HttpClientResponse with data, headers, and rawData', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({ additionalProperties: new Map([['test', true]]) });
+
+ router.post('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Custom-Header', 'test-value');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await postPingPostRequest({
+ payload: requestMessage,
+ server: `http://localhost:${port}`
+ });
+
+ expect(response.data).toBeDefined();
+ expect(response.data.marshal()).toEqual(replyMessage.marshal());
+ expect(response.status).toBe(200);
+ expect(response.statusText).toBe('OK');
+ expect(response.headers).toBeDefined();
+ expect(response.headers['content-type']).toContain('application/json');
+ expect(response.rawData).toBeDefined();
+ });
+ });
+
+ it('should include pagination info from response headers', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({ additionalProperties: new Map([['page', 1]]) });
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '100');
+ res.setHeader('X-Has-More', 'true');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'offset', offset: 0, limit: 20 }
+ });
+
+ expect(response.pagination).toBeDefined();
+ expect(response.pagination?.totalCount).toBe(100);
+ expect(response.pagination?.hasMore).toBe(true);
+ expect(response.pagination?.currentOffset).toBe(0);
+ expect(response.pagination?.limit).toBe(20);
+ });
+ });
+ });
+
+ describe('query parameters', () => {
+ it('should add query parameters to URL', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedFilter: string | undefined;
+ let receivedSort: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedFilter = req.query.filter as string;
+ receivedSort = req.query.sort as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ queryParams: {
+ filter: 'active',
+ sort: 'name'
+ }
+ });
+
+ expect(receivedFilter).toBe('active');
+ expect(receivedSort).toBe('name');
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ it('should throw standardized error for 401', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.status(401).json({ error: 'Unauthorized' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`
+ })).rejects.toThrow('Unauthorized');
+ });
+ });
+
+ it('should throw standardized error for 403', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.status(403).json({ error: 'Forbidden' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`
+ })).rejects.toThrow('Forbidden');
+ });
+ });
+
+ it('should throw standardized error for 404', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.status(404).json({ error: 'Not Found' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`
+ })).rejects.toThrow('Not Found');
+ });
+ });
+
+ it('should throw standardized error for 500', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.status(500).json({ error: 'Internal Server Error' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`
+ })).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
new file mode 100644
index 00000000..1c020e98
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/hooks.spec.ts
@@ -0,0 +1,479 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ getPingGetRequest,
+ HttpHooks,
+ RetryConfig,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - Hooks', () => {
+ describe('beforeRequest hook', () => {
+ it('should call beforeRequest hook and modify request', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedCustomHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedCustomHeader = req.headers['x-custom-hook-header'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => ({
+ ...params,
+ headers: {
+ ...params.headers,
+ 'X-Custom-Hook-Header': 'hook-value'
+ }
+ })
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(receivedCustomHeader).toBe('hook-value');
+ });
+ });
+
+ it('should support async beforeRequest hook', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedTimestamp: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedTimestamp = req.headers['x-timestamp'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: async (params) => {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ return {
+ ...params,
+ headers: {
+ ...params.headers,
+ 'X-Timestamp': Date.now().toString()
+ }
+ };
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(receivedTimestamp).toBeDefined();
+ expect(parseInt(receivedTimestamp!)).toBeGreaterThan(0);
+ });
+ });
+
+ it('should use beforeRequest hook to add authentication', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => ({
+ ...params,
+ headers: {
+ ...params.headers,
+ 'Authorization': 'Bearer token-from-hook'
+ }
+ })
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer token-from-hook');
+ });
+ });
+
+ it('should modify URL in beforeRequest hook', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedPath: string | undefined;
+
+ router.get('/custom-path', (req, res) => {
+ receivedPath = req.path;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => ({
+ ...params,
+ url: params.url.replace('/ping', '/custom-path')
+ })
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(receivedPath).toBe('/custom-path');
+ });
+ });
+ });
+
+ describe('afterResponse hook', () => {
+ it('should call afterResponse hook', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let afterResponseCalled = false;
+ let capturedStatus: number | undefined;
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ afterResponse: (response, params) => {
+ afterResponseCalled = true;
+ capturedStatus = response.status;
+ return response;
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(afterResponseCalled).toBe(true);
+ expect(capturedStatus).toBe(200);
+ });
+ });
+
+ it('should capture request/response with afterResponse for logging', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ const logEntries: { url: string; status: number; duration: number }[] = [];
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ let startTime: number;
+
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => {
+ startTime = Date.now();
+ return params;
+ },
+ afterResponse: (response, params) => {
+ logEntries.push({
+ url: params.url,
+ status: response.status,
+ duration: Date.now() - startTime
+ });
+ return response;
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(logEntries.length).toBe(1);
+ expect(logEntries[0].url).toContain('/ping');
+ expect(logEntries[0].status).toBe(200);
+ expect(logEntries[0].duration).toBeGreaterThanOrEqual(0);
+ });
+ });
+ });
+
+ describe('onError hook', () => {
+ it('should call onError hook on failure', async () => {
+ const { app, router, port } = createTestServer();
+
+ let onErrorCalled = false;
+ let capturedError: Error | undefined;
+
+ router.get('/ping', (req, res) => {
+ res.status(404).json({ error: 'Not Found' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ onError: (error, params) => {
+ onErrorCalled = true;
+ capturedError = error;
+ return error;
+ }
+ };
+
+ try {
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+ } catch (error) {
+ // Expected to throw
+ }
+
+ expect(onErrorCalled).toBe(true);
+ expect(capturedError?.message).toBe('Not Found');
+ });
+ });
+
+ it('should transform error in onError hook', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.status(503).json({ error: 'Service Unavailable', retryAfter: 60 });
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ onError: (error, params) => {
+ const enhancedError = new Error(`Request to ${params.url} failed: ${error.message}`);
+ return enhancedError;
+ }
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ })).rejects.toThrow(/Request to.*failed/);
+ });
+ });
+
+ it('should handle async onError hook', async () => {
+ const { app, router, port } = createTestServer();
+
+ let asyncOperationCompleted = false;
+
+ router.get('/ping', (req, res) => {
+ res.status(500).json({ error: 'Server Error' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ onError: async (error, params) => {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ asyncOperationCompleted = true;
+ return error;
+ }
+ };
+
+ try {
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+ } catch (error) {
+ // Expected
+ }
+
+ expect(asyncOperationCompleted).toBe(true);
+ });
+ });
+ });
+
+ describe('makeRequest hook', () => {
+ it('should allow custom makeRequest implementation', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let customMakeRequestCalled = false;
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ makeRequest: async (params) => {
+ customMakeRequestCalled = true;
+ const NodeFetch = await import('node-fetch');
+ return NodeFetch.default(params.url, {
+ method: params.method,
+ headers: params.headers,
+ body: params.body
+ }) as any;
+ }
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(customMakeRequestCalled).toBe(true);
+ expect(response.data.marshal()).toEqual(replyMessage.marshal());
+ });
+ });
+ });
+
+ describe('combined hooks', () => {
+ it('should use all hooks together', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ const hookCalls: string[] = [];
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => {
+ hookCalls.push('beforeRequest');
+ return params;
+ },
+ makeRequest: async (params) => {
+ hookCalls.push('makeRequest');
+ const NodeFetch = await import('node-fetch');
+ return NodeFetch.default(params.url, {
+ method: params.method,
+ headers: params.headers,
+ body: params.body
+ }) as any;
+ },
+ afterResponse: (response, params) => {
+ hookCalls.push('afterResponse');
+ return response;
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks
+ });
+
+ expect(hookCalls).toEqual(['beforeRequest', 'makeRequest', 'afterResponse']);
+ });
+ });
+
+ it('should work with hooks and retry together', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ const hookCalls: string[] = [];
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(500).json({ error: 'Server Error' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => {
+ hookCalls.push('beforeRequest');
+ return params;
+ },
+ afterResponse: (response, params) => {
+ hookCalls.push(`afterResponse-${response.status}`);
+ return response;
+ }
+ };
+
+ const retry: RetryConfig = {
+ maxRetries: 2,
+ initialDelayMs: 50,
+ retryableStatusCodes: [500]
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks,
+ retry
+ });
+
+ expect(hookCalls).toContain('beforeRequest');
+ expect(hookCalls).toContain('afterResponse-200');
+ expect(requestCount).toBe(2);
+ });
+ });
+
+ it('should work with hooks and pagination', async () => {
+ const { app, router, port } = createTestServer();
+
+ const hookCalls: { method: string; offset?: string }[] = [];
+
+ router.get('/ping', (req, res) => {
+ const replyMessage = new Pong({});
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '60');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const hooks: HttpHooks = {
+ beforeRequest: (params) => {
+ const url = new URL(params.url);
+ hookCalls.push({
+ method: params.method,
+ offset: url.searchParams.get('offset') || undefined
+ });
+ return params;
+ }
+ };
+
+ const page1 = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ hooks,
+ pagination: { type: 'offset', offset: 0, limit: 20 }
+ });
+
+ await page1.getNextPage!();
+
+ expect(hookCalls.length).toBe(2);
+ expect(hookCalls[0].offset).toBe('0');
+ expect(hookCalls[1].offset).toBe('20');
+ });
+ });
+ });
+});
diff --git a/test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts b/test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts
deleted file mode 100644
index dae1465d..00000000
--- a/test/runtime/typescript/test/channels/request_reply/http_client/http_client.spec.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-/* eslint-disable no-console */
-import { Ping } from "../../../../src/request-reply/payloads/Ping";
-import { Pong } from "../../../../src/request-reply/payloads/Pong";
-import { createTestServer, runWithServer } from './test-utils';
-import { NotFound } from '../../../../src/request-reply/payloads/NotFound';
-import {postPingPostRequest, getPingGetRequest, putPingPutRequest,
- patchPingPatchRequest, deletePingDeleteRequest, headPingHeadRequest,
- optionsPingOptionsRequest, getMultiStatusResponse } from '../../../../src/request-reply/channels/http_client';
-
-jest.setTimeout(10000);
-describe('http_fetch', () => {
- describe('channels', () => {
- it('should be able to make POST request', async () => {
- const { app, router, port } = createTestServer();
-
- const requestMessage = new Ping({});
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
- let requestMethod: string;
-
- router.post('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- res.write(replyMessage.marshal());
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
- payload: requestMessage,
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
- expect(requestMethod).toEqual('POST');
- });
- });
-
- it('should be able to make GET request', async () => {
- const { app, router, port } = createTestServer();
-
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
- let requestMethod: string;
-
- router.get('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- res.write(replyMessage.marshal());
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await getPingGetRequest({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
- expect(requestMethod).toEqual('GET');
- });
- });
-
- it('should be able to make PUT request', async () => {
- const { app, router, port } = createTestServer();
-
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
- let requestMethod: string;
-
- router.put('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- res.write(replyMessage.marshal());
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await putPingPutRequest({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
- expect(requestMethod).toEqual('PUT');
- });
- });
-
- it('should be able to make PATCH request', async () => {
- const { app, router, port } = createTestServer();
-
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
- let requestMethod: string;
-
- router.patch('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- res.write(replyMessage.marshal());
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await patchPingPatchRequest({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
- expect(requestMethod).toEqual('PATCH');
- });
- });
-
- it('should be able to make DELETE request', async () => {
- const { app, router, port } = createTestServer();
-
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
- let requestMethod: string;
-
- router.delete('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- res.write(replyMessage.marshal());
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await deletePingDeleteRequest({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
- expect(requestMethod).toEqual('DELETE');
- });
- });
-
- it('should be able to make HEAD request', async () => {
- const { app, router, port } = createTestServer();
-
- let requestMethod: string;
-
- router.head('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- // HEAD responses typically don't have a body
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- try {
- await headPingHeadRequest({
- server: `http://localhost:${port}`
- });
- // If we reach here, the request didn't throw (which is what we expect for HEAD)
- expect(requestMethod).toEqual('HEAD');
- } catch (error) {
- // HEAD will likely fail because it's trying to parse JSON from an empty response
- // This is actually expected behavior for this test framework
- expect(requestMethod).toEqual('HEAD');
- expect(error.message).toContain('Unexpected end of JSON input');
- }
- });
- });
-
- it('should be able to make OPTIONS request', async () => {
- const { app, router, port } = createTestServer();
-
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
- let requestMethod: string;
-
- router.options('/ping', (req, res) => {
- requestMethod = req.method;
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
- res.write(replyMessage.marshal());
- res.end();
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await optionsPingOptionsRequest({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
- expect(requestMethod).toEqual('OPTIONS');
- });
- });
-
- it('should handle multi-status 200 response', async () => {
- const { app, router, port } = createTestServer();
- const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
-
- router.get('/ping', (req, res) => {
- res.setHeader('Content-Type', 'application/json');
- res.status(200).send(replyMessage.marshal());
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await getMultiStatusResponse({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal());
- });
- });
- it('should handle multi-status 404 response', async () => {
- const { app, router, port } = createTestServer();
- const replyMessage = new NotFound({additionalProperties: new Map([['test', true]])});
-
- router.get('/ping', (req, res) => {
- res.setHeader('Content-Type', 'application/json');
- res.status(404).send(replyMessage.marshal());
- });
-
- return runWithServer(app, port, async () => {
- const receivedReplyMessage = await getMultiStatusResponse({
- server: `http://localhost:${port}`
- });
- expect(receivedReplyMessage.marshal()).toEqual(replyMessage.marshal());
- });
- });
-
- });
-});
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
new file mode 100644
index 00000000..8edb5e35
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/methods.spec.ts
@@ -0,0 +1,457 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { Ping } from "../../../../src/request-reply/payloads/Ping";
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ postPingPostRequest,
+ getPingGetRequest,
+ putPingPutRequest,
+ deletePingDeleteRequest,
+ patchPingPatchRequest,
+ headPingHeadRequest,
+ optionsPingOptionsRequest,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - HTTP Methods', () => {
+ describe('GET method', () => {
+ it('should make GET request without body', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedMethod: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedMethod = req.method;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`
+ });
+
+ expect(receivedMethod).toBe('GET');
+ expect(response.status).toBe(200);
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+
+ describe('POST method', () => {
+ it('should make POST request with body', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let receivedMethod: string | undefined;
+ let receivedBody: any;
+
+ router.post('/ping', (req, res) => {
+ receivedMethod = req.method;
+ receivedBody = req.body;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await postPingPostRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage
+ });
+
+ expect(receivedMethod).toBe('POST');
+ expect(receivedBody).toBeDefined();
+ expect(response.status).toBe(200);
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+
+ describe('PUT method', () => {
+ it('should make PUT request with body', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let receivedMethod: string | undefined;
+ let receivedBody: any;
+
+ router.put('/ping', (req, res) => {
+ receivedMethod = req.method;
+ receivedBody = req.body;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await putPingPutRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage
+ });
+
+ expect(receivedMethod).toBe('PUT');
+ expect(receivedBody).toBeDefined();
+ expect(response.status).toBe(200);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should support PUT with authentication', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.put('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ await putPingPutRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage,
+ auth: { type: 'bearer', token: 'put-token' }
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer put-token');
+ });
+ });
+
+ it('should support PUT with query params', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let receivedQuery: any;
+
+ router.put('/ping', (req, res) => {
+ receivedQuery = req.query;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ await putPingPutRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage,
+ queryParams: { version: '2' }
+ });
+
+ expect(receivedQuery.version).toBe('2');
+ });
+ });
+ });
+
+ describe('DELETE method', () => {
+ it('should make DELETE request', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedMethod: string | undefined;
+
+ router.delete('/ping', (req, res) => {
+ receivedMethod = req.method;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await deletePingDeleteRequest({
+ server: `http://localhost:${port}`
+ });
+
+ expect(receivedMethod).toBe('DELETE');
+ expect(response.status).toBe(200);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should support DELETE with authentication', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.delete('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ await deletePingDeleteRequest({
+ server: `http://localhost:${port}`,
+ auth: { type: 'bearer', token: 'delete-token' }
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer delete-token');
+ });
+ });
+ });
+
+ describe('PATCH method', () => {
+ it('should make PATCH request with body', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let receivedMethod: string | undefined;
+ let receivedBody: any;
+
+ router.patch('/ping', (req, res) => {
+ receivedMethod = req.method;
+ receivedBody = req.body;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await patchPingPatchRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage
+ });
+
+ expect(receivedMethod).toBe('PATCH');
+ expect(receivedBody).toBeDefined();
+ expect(response.status).toBe(200);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should support PATCH with pagination', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let receivedOffset: string | undefined;
+
+ router.patch('/ping', (req, res) => {
+ receivedOffset = req.query.offset as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '50');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await patchPingPatchRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage,
+ pagination: { type: 'offset', offset: 10, limit: 5 }
+ });
+
+ expect(receivedOffset).toBe('10');
+ expect(response.pagination).toBeDefined();
+ });
+ });
+ });
+
+ describe('HEAD method', () => {
+ it('should make HEAD request and handle empty response', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedMethod: string | undefined;
+
+ router.head('/ping', (req, res) => {
+ receivedMethod = req.method;
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Custom-Header', 'head-value');
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ // 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}`
+ });
+ } catch (error) {
+ // Expected - HEAD responses have no body to parse
+ }
+
+ expect(receivedMethod).toBe('HEAD');
+ });
+ });
+
+ it('should support HEAD with authentication', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedAuthHeader: string | undefined;
+ let receivedApiKey: string | undefined;
+
+ router.head('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ receivedApiKey = req.headers['x-api-key'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ try {
+ await headPingHeadRequest({
+ server: `http://localhost:${port}`,
+ auth: { type: 'apiKey', key: 'head-key' }
+ });
+ } catch (error) {
+ // Expected - HEAD responses have no body
+ }
+
+ expect(receivedApiKey).toBe('head-key');
+ });
+ });
+ });
+
+ describe('OPTIONS method', () => {
+ it('should make OPTIONS request and handle empty response', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedMethod: string | undefined;
+ let receivedAllowHeader: string | undefined;
+
+ router.options('/ping', (req, res) => {
+ receivedMethod = req.method;
+ res.setHeader('Allow', 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS');
+ res.setHeader('Content-Type', 'application/json');
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ // OPTIONS requests typically return no body
+ try {
+ await optionsPingOptionsRequest({
+ server: `http://localhost:${port}`
+ });
+ } catch (error) {
+ // Expected - OPTIONS responses typically have no body
+ }
+
+ expect(receivedMethod).toBe('OPTIONS');
+ });
+ });
+ });
+
+ describe('error handling across methods', () => {
+ it('should handle 404 for PUT', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+
+ router.put('/ping', (req, res) => {
+ res.status(404).json({ error: 'Not Found' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(putPingPutRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage
+ })).rejects.toThrow('Not Found');
+ });
+ });
+
+ it('should handle 500 for DELETE', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.delete('/ping', (req, res) => {
+ res.status(500).json({ error: 'Internal Server Error' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(deletePingDeleteRequest({
+ server: `http://localhost:${port}`
+ })).rejects.toThrow('Internal Server Error');
+ });
+ });
+
+ it('should handle 403 for PATCH', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+
+ router.patch('/ping', (req, res) => {
+ res.status(403).json({ error: 'Forbidden' });
+ });
+
+ return runWithServer(app, port, async () => {
+ await expect(patchPingPatchRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage
+ })).rejects.toThrow('Forbidden');
+ });
+ });
+ });
+
+ describe('retry across methods', () => {
+ it('should retry PUT on 503', async () => {
+ const { app, router, port } = createTestServer();
+
+ const requestMessage = new Ping({});
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.put('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(503).json({ error: 'Service Unavailable' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await putPingPutRequest({
+ server: `http://localhost:${port}`,
+ payload: requestMessage,
+ retry: { maxRetries: 3, initialDelayMs: 50, retryableStatusCodes: [503] }
+ });
+
+ expect(requestCount).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should retry DELETE on 502', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.delete('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(502).json({ error: 'Bad Gateway' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await deletePingDeleteRequest({
+ server: `http://localhost:${port}`,
+ retry: { maxRetries: 3, initialDelayMs: 50, retryableStatusCodes: [502] }
+ });
+
+ expect(requestCount).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000..141c5a82
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/oauth2.spec.ts
@@ -0,0 +1,667 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { createTestServer, runWithServer, createTokenResponse } from './test-utils';
+import {
+ getPingGetRequest,
+ OAuth2Auth,
+ RetryConfig,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - OAuth2', () => {
+ describe('client credentials flow', () => {
+ it('should handle OAuth2 client credentials flow', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let tokenRequestReceived = false;
+ let tokenRequestBody: string | undefined;
+ let apiRequestAuthHeader: string | undefined;
+ const refreshedTokens: { accessToken: string; refreshToken?: string }[] = [];
+
+ router.post('/oauth/token', (req, res) => {
+ tokenRequestReceived = true;
+ tokenRequestBody = JSON.stringify(req.body);
+ res.json(createTokenResponse({
+ accessToken: 'new-access-token-from-flow',
+ refreshToken: 'new-refresh-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ apiRequestAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ tokenUrl: `http://localhost:${port}/oauth/token`,
+ scopes: ['read', 'write'],
+ onTokenRefresh: (tokens) => {
+ refreshedTokens.push(tokens);
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(tokenRequestReceived).toBe(true);
+ expect(apiRequestAuthHeader).toBe('Bearer new-access-token-from-flow');
+ expect(refreshedTokens.length).toBe(1);
+ expect(refreshedTokens[0].accessToken).toBe('new-access-token-from-flow');
+ });
+ });
+
+ it('should reject OAuth2 client credentials flow without tokenUrl', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.json({ error: 'should not reach here' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'test-client-id'
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ })).rejects.toThrow('OAuth2 Client Credentials flow requires tokenUrl');
+ });
+ });
+ });
+
+ describe('password flow', () => {
+ it('should handle OAuth2 password flow', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let tokenRequestReceived = false;
+ let receivedUsername: string | undefined;
+ let receivedPassword: string | undefined;
+ let apiRequestAuthHeader: string | undefined;
+
+ router.post('/oauth/token', (req, res) => {
+ tokenRequestReceived = true;
+ receivedUsername = req.body.username;
+ receivedPassword = req.body.password;
+ res.json(createTokenResponse({
+ accessToken: 'password-flow-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ apiRequestAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'password',
+ clientId: 'test-client-id',
+ tokenUrl: `http://localhost:${port}/oauth/token`,
+ username: 'testuser',
+ password: 'testpass'
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(tokenRequestReceived).toBe(true);
+ expect(receivedUsername).toBe('testuser');
+ expect(receivedPassword).toBe('testpass');
+ expect(apiRequestAuthHeader).toBe('Bearer password-flow-token');
+ });
+ });
+
+ it('should reject OAuth2 password flow without required fields', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ res.json({ error: 'should not reach here' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'password',
+ clientId: 'test-client-id',
+ tokenUrl: `http://localhost:${port}/oauth/token`
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ })).rejects.toThrow('OAuth2 Password flow requires username');
+ });
+ });
+ });
+
+ describe('token refresh', () => {
+ it('should refresh OAuth2 token on 401 response', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ let refreshRequestReceived = false;
+ const authHeaders: string[] = [];
+
+ router.post('/oauth/token', (req, res) => {
+ refreshRequestReceived = true;
+ res.json(createTokenResponse({
+ accessToken: 'refreshed-token',
+ refreshToken: 'new-refresh-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ authHeaders.push(req.headers.authorization as string);
+
+ if (requestCount === 1) {
+ res.status(401).json({ error: 'Token expired' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ accessToken: 'expired-token',
+ refreshToken: 'valid-refresh-token',
+ clientId: 'test-client-id',
+ tokenUrl: `http://localhost:${port}/oauth/token`
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(refreshRequestReceived).toBe(true);
+ expect(requestCount).toBe(2);
+ expect(authHeaders[0]).toBe('Bearer expired-token');
+ expect(authHeaders[1]).toBe('Bearer refreshed-token');
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+
+ describe('OAuth2 with retry logic', () => {
+ it('should retry authenticated request after client_credentials flow when server returns 503', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let tokenRequestCount = 0;
+ let authenticatedRequestCount = 0;
+ const retryCalls: number[] = [];
+
+ router.post('/oauth/token', (req, res) => {
+ tokenRequestCount++;
+ res.json(createTokenResponse({
+ accessToken: 'flow-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ // Only count authenticated requests (those with Bearer token)
+ const hasAuth = req.headers.authorization?.startsWith('Bearer ');
+ if (hasAuth) {
+ authenticatedRequestCount++;
+ if (authenticatedRequestCount < 3) {
+ res.status(503).json({ error: 'Service Unavailable' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ } else {
+ // Initial request without auth - let it pass to trigger OAuth flow
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ tokenUrl: `http://localhost:${port}/oauth/token`
+ };
+
+ const retry: RetryConfig = {
+ maxRetries: 5,
+ initialDelayMs: 50,
+ retryableStatusCodes: [503],
+ onRetry: (attempt) => {
+ retryCalls.push(attempt);
+ }
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth,
+ retry
+ });
+
+ expect(tokenRequestCount).toBe(1);
+ // 3 authenticated requests: 2 failed with 503, 1 succeeded
+ expect(authenticatedRequestCount).toBe(3);
+ expect(retryCalls.length).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should retry authenticated request after password flow when server returns 500', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let tokenRequestCount = 0;
+ let authenticatedRequestCount = 0;
+
+ router.post('/oauth/token', (req, res) => {
+ tokenRequestCount++;
+ res.json(createTokenResponse({
+ accessToken: 'password-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ const hasAuth = req.headers.authorization?.startsWith('Bearer ');
+ if (hasAuth) {
+ authenticatedRequestCount++;
+ if (authenticatedRequestCount < 2) {
+ res.status(500).json({ error: 'Internal Server Error' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'password',
+ clientId: 'test-client-id',
+ tokenUrl: `http://localhost:${port}/oauth/token`,
+ username: 'user',
+ password: 'pass'
+ };
+
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50,
+ retryableStatusCodes: [500]
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth,
+ retry
+ });
+
+ expect(tokenRequestCount).toBe(1);
+ // 2 authenticated requests: 1 failed with 500, 1 succeeded
+ expect(authenticatedRequestCount).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should retry authenticated request after token refresh when server returns 502', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let refreshRequestCount = 0;
+ let apiRequestCount = 0;
+ const authHeaders: string[] = [];
+
+ router.post('/oauth/token', (req, res) => {
+ refreshRequestCount++;
+ res.json(createTokenResponse({
+ accessToken: 'refreshed-token',
+ refreshToken: 'new-refresh-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ apiRequestCount++;
+ authHeaders.push(req.headers.authorization as string);
+
+ if (apiRequestCount === 1) {
+ res.status(401).json({ error: 'Token expired' });
+ } else if (apiRequestCount < 4) {
+ res.status(502).json({ error: 'Bad Gateway' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ accessToken: 'expired-token',
+ refreshToken: 'valid-refresh-token',
+ clientId: 'test-client-id',
+ tokenUrl: `http://localhost:${port}/oauth/token`
+ };
+
+ const retry: RetryConfig = {
+ maxRetries: 5,
+ initialDelayMs: 50,
+ retryableStatusCodes: [502]
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth,
+ retry
+ });
+
+ expect(refreshRequestCount).toBe(1);
+ expect(apiRequestCount).toBe(4);
+ expect(authHeaders[0]).toBe('Bearer expired-token');
+ expect(authHeaders[1]).toBe('Bearer refreshed-token');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should fail after max retries exhausted on OAuth2 authenticated request', async () => {
+ const { app, router, port } = createTestServer();
+
+ let tokenRequestCount = 0;
+ let authenticatedRequestCount = 0;
+
+ router.post('/oauth/token', (req, res) => {
+ tokenRequestCount++;
+ res.json(createTokenResponse({
+ accessToken: 'flow-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ const hasAuth = req.headers.authorization?.startsWith('Bearer ');
+ if (hasAuth) {
+ authenticatedRequestCount++;
+ res.status(503).json({ error: 'Service Unavailable' });
+ } else {
+ // Initial request without auth - let it pass to trigger OAuth flow
+ res.setHeader('Content-Type', 'application/json');
+ res.write(JSON.stringify({}));
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ tokenUrl: `http://localhost:${port}/oauth/token`
+ };
+
+ const retry: RetryConfig = {
+ maxRetries: 2,
+ initialDelayMs: 50,
+ retryableStatusCodes: [503]
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth,
+ retry
+ })).rejects.toThrow();
+
+ expect(tokenRequestCount).toBe(1);
+ // maxRetries=2: attempt 0 (initial), attempt 1 (retry), then shouldRetry(attempt=2) returns false
+ // So 2 total authenticated requests before giving up
+ expect(authenticatedRequestCount).toBe(2);
+ });
+ });
+
+ it('should apply exponential backoff on OAuth2 authenticated request retries', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let apiRequestCount = 0;
+ const delays: number[] = [];
+
+ router.post('/oauth/token', (req, res) => {
+ res.json(createTokenResponse({
+ accessToken: 'flow-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ apiRequestCount++;
+ if (apiRequestCount < 4) {
+ res.status(503).json({ error: 'Service Unavailable' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ tokenUrl: `http://localhost:${port}/oauth/token`
+ };
+
+ const retry: RetryConfig = {
+ maxRetries: 5,
+ initialDelayMs: 100,
+ backoffMultiplier: 2,
+ retryableStatusCodes: [503],
+ onRetry: (attempt, delay) => {
+ delays.push(delay);
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth,
+ retry
+ });
+
+ expect(delays.length).toBe(3);
+ expect(delays[0]).toBe(100);
+ expect(delays[1]).toBe(200);
+ expect(delays[2]).toBe(400);
+ });
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should skip token flow for unsupported OAuth2 flow types', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let tokenEndpointHit = false;
+ let receivedAuthHeader: string | undefined;
+
+ router.post('/oauth/token', (req, res) => {
+ tokenEndpointHit = true;
+ res.json(createTokenResponse({ accessToken: 'should-not-get-this' }));
+ });
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ // 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`,
+ accessToken: 'pre-existing-token'
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ // Token endpoint should NOT be hit because we have accessToken
+ expect(tokenEndpointHit).toBe(false);
+ expect(receivedAuthHeader).toBe('Bearer pre-existing-token');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should use accessToken directly when no flow is specified', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedAuthHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedAuthHeader = req.headers.authorization;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ // When no flow is specified but accessToken is provided,
+ // it should be used directly without fetching
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ accessToken: 'existing-token'
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(receivedAuthHeader).toBe('Bearer existing-token');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should use existing accessToken without fetching new token', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let tokenEndpointHit = false;
+
+ router.post('/oauth/token', (req, res) => {
+ tokenEndpointHit = true;
+ res.json(createTokenResponse({ accessToken: 'new-token' }));
+ });
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ flow: 'client_credentials',
+ clientId: 'test-client',
+ tokenUrl: `http://localhost:${port}/oauth/token`,
+ accessToken: 'already-have-token' // This should prevent token fetch
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ // Should not hit token endpoint since we already have accessToken
+ expect(tokenEndpointHit).toBe(false);
+ });
+ });
+
+ it('should call onTokenRefresh callback when token is refreshed', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ const refreshedTokens: { accessToken: string; refreshToken?: string }[] = [];
+
+ router.post('/oauth/token', (req, res) => {
+ res.json(createTokenResponse({
+ accessToken: 'new-access-token',
+ refreshToken: 'new-refresh-token'
+ }));
+ });
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount === 1) {
+ res.status(401).json({ error: 'Token expired' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const auth: OAuth2Auth = {
+ type: 'oauth2',
+ accessToken: 'expired-token',
+ refreshToken: 'valid-refresh',
+ clientId: 'test-client',
+ tokenUrl: `http://localhost:${port}/oauth/token`,
+ onTokenRefresh: (tokens) => {
+ refreshedTokens.push(tokens);
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ auth
+ });
+
+ expect(refreshedTokens.length).toBe(1);
+ expect(refreshedTokens[0].accessToken).toBe('new-access-token');
+ expect(refreshedTokens[0].refreshToken).toBe('new-refresh-token');
+ });
+ });
+ });
+});
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 7fe3d3d4..586a0091 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
@@ -11,7 +11,7 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
it('should authenticate with OAuth2 Client Credentials flow', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const CLIENT_ID = 'test-client-id';
@@ -24,11 +24,11 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
if (req.body.grant_type !== 'client_credentials') {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid grant type' });
}
-
+
// Validate client credentials from body or Basic auth header
const authHeader = req.headers.authorization;
let clientId, clientSecret;
-
+
if (authHeader && authHeader.startsWith('Basic ')) {
// Extract credentials from Basic auth header
const base64Credentials = authHeader.split(' ')[1];
@@ -39,44 +39,45 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
clientId = req.body.client_id;
clientSecret = req.body.client_secret;
}
-
+
if (clientId !== CLIENT_ID || (CLIENT_SECRET && clientSecret !== CLIENT_SECRET)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
-
+
// Return a successful token response
res.json(createTokenResponse({
accessToken: ACCESS_TOKEN,
expiresIn: 3600
}));
});
-
+
// Protected API endpoint that requires Bearer token
router.post('/ping', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body);
}
-
+
// Validate the Bearer token
const token = authHeader.split(' ')[1];
if (token !== ACCESS_TOKEN) {
return res.status(401).json(TestResponses.unauthorized('Invalid Token').body);
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
// Mock onTokenRefresh callback
const onTokenRefresh = jest.fn();
-
- const receivedReplyMessage = await postPingPostRequest({
+
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'client_credentials',
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
@@ -84,39 +85,40 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
onTokenRefresh
}
});
-
+
// Verify that the token refresh callback was called with the expected tokens
expect(onTokenRefresh).toHaveBeenCalledWith({
accessToken: ACCESS_TOKEN,
refreshToken: undefined,
expiresIn: 3600
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should handle OAuth2 client credentials errors', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const CLIENT_ID = 'test-client-id';
// Mock token endpoint that always fails
router.post('/oauth/token', (req, res) => {
- res.status(401).json({
- error: 'invalid_client',
- error_description: 'Invalid client credentials'
+ res.status(401).json({
+ error: 'invalid_client',
+ error_description: 'Invalid client credentials'
});
});
-
+
return runWithServer(app, port, async () => {
try {
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'client_credentials',
clientId: CLIENT_ID,
tokenUrl: `http://localhost:${port}/oauth/token`
@@ -128,17 +130,18 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
}
});
});
-
+
it('should handle missing clientId in client credentials flow', async () => {
const port = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779;
const requestMessage = new Ping({});
-
+
try {
// Using as any to bypass TypeScript's type checking for this test
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'client_credentials',
tokenUrl: `http://localhost:${port}/oauth/token`
} as any
@@ -148,11 +151,11 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
expect(error.message).toBe('OAuth2 Client Credentials flow requires clientId');
}
});
-
+
it('should handle client credentials with Basic authentication', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const CLIENT_ID = 'test-client-id';
@@ -166,17 +169,17 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
if (req.body.grant_type !== 'client_credentials') {
return res.status(400).json(TestResponses.badRequest('Invalid grant type').body);
}
-
+
// Check if Basic auth is used
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
usedBasicAuth = true;
-
+
// Extract and validate credentials
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [clientId, clientSecret] = credentials.split(':');
-
+
if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) {
return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body);
}
@@ -184,48 +187,49 @@ describe('HTTP Client - OAuth2 Client Credentials Flow', () => {
// If not using Basic auth, client credentials should be in the request body
return res.status(401).json(TestResponses.unauthorized('Basic authentication expected').body);
}
-
+
// Return a successful token response
res.json(createTokenResponse({
accessToken: ACCESS_TOKEN,
expiresIn: 3600
}));
});
-
+
// Protected API endpoint
router.post('/ping', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body);
}
-
+
// Validate the Bearer token
const token = authHeader.split(' ')[1];
if (token !== ACCESS_TOKEN) {
return res.status(401).json(TestResponses.unauthorized('Invalid Token').body);
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'client_credentials',
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
tokenUrl: `http://localhost:${port}/oauth/token`
}
});
-
+
// Verify that Basic authentication was used
expect(usedBasicAuth).toBe(true);
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
});
-});
\ No newline at end of file
+});
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 4fb52639..b52a9ccf 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
@@ -1,168 +1,210 @@
/* eslint-disable no-console */
+/**
+ * OAuth2 Pre-obtained Access Token Tests
+ *
+ * NOTE: The implicit flow has been intentionally removed from the generated HTTP client.
+ *
+ * The implicit flow is a browser-based OAuth2 flow that requires:
+ * - Browser redirects to authorization server
+ * - User interaction for consent
+ * - Token returned via URL fragment (#access_token=...)
+ *
+ * This flow cannot be implemented in server-side generated code because:
+ * 1. It requires browser interaction
+ * 2. It's deprecated per OAuth 2.1 specification
+ * 3. It has security vulnerabilities (token exposure in URLs)
+ *
+ * For browser-based applications:
+ * - Obtain tokens using your frontend framework's OAuth2 library
+ * - Pass the obtained access token to the generated client:
+ * auth: { type: 'oauth2', accessToken: 'your-token' }
+ *
+ * For server-to-server authentication:
+ * - Use client_credentials flow: auth: { type: 'oauth2', flow: 'client_credentials', ... }
+ * - Use password flow (legacy): auth: { type: 'oauth2', flow: 'password', ... }
+ */
+
import { Ping } from "../../../../src/request-reply/payloads/Ping";
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
import { createTestServer, runWithServer } from './test-utils';
import { postPingPostRequest } from '../../../../src/request-reply/channels/http_client';
+import bodyParser from 'body-parser';
jest.setTimeout(10000);
-describe('HTTP Client - OAuth2 Implicit Flow', () => {
- describe('OAuth2 Implicit Flow', () => {
- it('should build proper authorization URL for implicit flow', async () => {
- const { app, port } = createTestServer();
-
+describe('HTTP Client - OAuth2 Pre-obtained Access Token', () => {
+ describe('Using pre-obtained access token (for browser-obtained tokens)', () => {
+ it('should authenticate with a pre-obtained access token', async () => {
+ const { app, router, port } = createTestServer();
+
const requestMessage = new Ping({});
- const CLIENT_ID = 'test-client-id';
- const REDIRECT_URI = 'http://localhost:3000/callback';
- const AUTH_URL = 'http://localhost:3000/oauth/authorize';
- const SCOPES = ['read', 'write'];
- const STATE = 'random-state-value';
- let capturedAuthUrl = '';
-
- // Mock the implicit redirect handler
- const onImplicitRedirect = jest.fn((authUrl) => {
- capturedAuthUrl = authUrl;
- // In a real app, this would redirect the user to the authorization URL
- });
+ const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
+ const ACCESS_TOKEN = 'pre-obtained-token-from-browser-flow';
- return runWithServer(app, port, async () => {
- try {
- await postPingPostRequest({
- payload: requestMessage,
- server: `http://localhost:${port}`,
- oauth2: {
- flow: 'implicit',
- clientId: CLIENT_ID,
- redirectUri: REDIRECT_URI,
- authorizationUrl: AUTH_URL,
- scopes: SCOPES,
- state: STATE,
- responseType: 'token',
- onImplicitRedirect
- }
- });
- throw new Error('Expected request to throw since implicit flow requires redirect');
- } catch (error) {
- // Verify that the redirect handler was called
- expect(onImplicitRedirect).toHaveBeenCalled();
-
- // Verify that the authorization URL was constructed correctly
- const url = new URL(capturedAuthUrl);
- expect(url.origin + url.pathname).toBe(AUTH_URL);
- expect(url.searchParams.get('client_id')).toBe(CLIENT_ID);
- expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI);
- expect(url.searchParams.get('response_type')).toBe('token');
- expect(url.searchParams.get('state')).toBe(STATE);
- expect(url.searchParams.get('scope')).toBe(SCOPES.join(' '));
-
- // Verify the correct error message
- expect(error.message).toBe('OAuth2 Implicit flow redirect initiated');
+ router.post('/ping', (req, res) => {
+ // Check if Authorization header is present
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'Unauthorized - Bearer Authentication Required' });
}
- });
- });
- it('should require clientId for implicit flow', async () => {
- const requestMessage = new Ping({});
- const AUTH_URL = 'http://localhost:3000/oauth/authorize';
+ // Validate the Bearer token
+ const token = authHeader.split(' ')[1];
+ if (token !== ACCESS_TOKEN) {
+ return res.status(401).json({ error: 'Unauthorized - Invalid Token' });
+ }
- try {
- await postPingPostRequest({
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ // 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:1234`,
- oauth2: {
- flow: 'implicit',
- authorizationUrl: AUTH_URL,
- // Missing clientId
- onImplicitRedirect: jest.fn()
- } as any
+ server: `http://localhost:${port}`,
+ auth: {
+ type: 'oauth2',
+ accessToken: ACCESS_TOKEN
+ }
});
- throw new Error('Expected request to fail due to missing clientId');
- } catch (error) {
- expect(error.message).toBe('OAuth2 Implicit flow requires clientId');
- }
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
+ expect(response.status).toEqual(200);
+ });
});
- it('should require redirectUri for implicit flow', async () => {
+ it('should support refresh token with pre-obtained access token', async () => {
+ const { app, router, port } = createTestServer();
+ app.use(bodyParser.urlencoded({ extended: true }));
+
const requestMessage = new Ping({});
- const CLIENT_ID = 'test-client-id';
- const AUTH_URL = 'http://localhost:3000/oauth/authorize';
+ const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
+ const EXPIRED_ACCESS_TOKEN = 'expired-token';
+ const REFRESH_TOKEN = 'refresh-token-from-original-auth';
+ const NEW_ACCESS_TOKEN = 'new-access-token';
+ const CLIENT_ID = 'my-client-id';
- try {
- await postPingPostRequest({
+ // Mock token endpoint for refresh
+ router.post('/oauth/token', (req, res) => {
+ if (req.body.grant_type === 'refresh_token' && req.body.refresh_token === REFRESH_TOKEN) {
+ return res.json({
+ access_token: NEW_ACCESS_TOKEN,
+ token_type: 'Bearer',
+ expires_in: 3600
+ });
+ }
+ res.status(401).json({ error: 'invalid_grant' });
+ });
+
+ // Protected endpoint
+ let requestCount = 0;
+ router.post('/ping', (req, res) => {
+ const token = req.headers.authorization?.split(' ')[1];
+
+ // First request with expired token returns 401
+ if (requestCount === 0 && token === EXPIRED_ACCESS_TOKEN) {
+ requestCount++;
+ return res.status(401).json({ error: 'Token Expired' });
+ }
+
+ // Second request with new token succeeds
+ if (token === NEW_ACCESS_TOKEN) {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ return;
+ }
+
+ res.status(401).json({ error: 'Invalid Token' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const onTokenRefresh = jest.fn();
+
+ // Use pre-obtained token with refresh capability
+ const response = await postPingPostRequest({
payload: requestMessage,
- server: `http://localhost:1234`,
- oauth2: {
- flow: 'implicit',
+ server: `http://localhost:${port}`,
+ auth: {
+ type: 'oauth2',
+ accessToken: EXPIRED_ACCESS_TOKEN,
+ refreshToken: REFRESH_TOKEN,
+ tokenUrl: `http://localhost:${port}/oauth/token`,
clientId: CLIENT_ID,
- authorizationUrl: AUTH_URL,
- // Missing redirectUri
- onImplicitRedirect: jest.fn()
+ onTokenRefresh
}
});
- throw new Error('Expected request to fail due to missing redirectUri');
- } catch (error) {
- expect(error.message).toBe('OAuth2 Implicit flow requires redirectUri');
- }
+
+ expect(onTokenRefresh).toHaveBeenCalled();
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
+ });
});
- it('should require onImplicitRedirect handler for implicit flow', async () => {
+ it('should work without any flow specified when access token is provided', async () => {
+ const { app, router, port } = createTestServer();
+
const requestMessage = new Ping({});
- const CLIENT_ID = 'test-client-id';
- const REDIRECT_URI = 'http://localhost:3000/callback';
- const AUTH_URL = 'http://localhost:3000/oauth/authorize';
+ const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
+ const ACCESS_TOKEN = 'simple-access-token';
+
+ router.post('/ping', (req, res) => {
+ const token = req.headers.authorization?.split(' ')[1];
+ if (token === ACCESS_TOKEN) {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ return;
+ }
+ res.status(401).json({ error: 'Unauthorized' });
+ });
- try {
- await postPingPostRequest({
+ return runWithServer(app, port, async () => {
+ // Simplest OAuth2 usage - just pass the access token
+ const response = await postPingPostRequest({
payload: requestMessage,
- server: `http://localhost:12345`,
- oauth2: {
- flow: 'implicit',
- clientId: CLIENT_ID,
- redirectUri: REDIRECT_URI,
- authorizationUrl: AUTH_URL
- // Missing onImplicitRedirect handler
+ server: `http://localhost:${port}`,
+ auth: {
+ type: 'oauth2',
+ accessToken: ACCESS_TOKEN
+ // No flow, tokenUrl, clientId needed
}
});
- throw new Error('Expected request to fail due to missing redirect handler');
- } catch (error) {
- expect(error.message).toBe('OAuth2 Implicit flow requires onImplicitRedirect handler');
- }
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
+ });
});
- it('should support different response types for implicit flow', async () => {
- const { app, port } = createTestServer();
+ it('should handle missing access token gracefully', async () => {
+ const { app, router, port } = createTestServer();
const requestMessage = new Ping({});
- const CLIENT_ID = 'test-client-id';
- const REDIRECT_URI = 'http://localhost:3000/callback';
- const AUTH_URL = 'http://localhost:3000/oauth/authorize';
- const RESPONSE_TYPE = 'id_token token';
- let capturedAuthUrl = '';
-
- // Mock the implicit redirect handler
- const onImplicitRedirect = jest.fn((authUrl) => {
- capturedAuthUrl = authUrl;
+
+ router.post('/ping', (req, res) => {
+ // Should not have Authorization header since no token provided
+ if (!req.headers.authorization) {
+ return res.status(401).json({ error: 'Unauthorized' });
+ }
+ res.json({ success: true });
});
return runWithServer(app, port, async () => {
try {
+ // OAuth2 config without access token and no server-side flow
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
- flow: 'implicit',
- clientId: CLIENT_ID,
- redirectUri: REDIRECT_URI,
- authorizationUrl: AUTH_URL,
- responseType: RESPONSE_TYPE,
- onImplicitRedirect
+ auth: {
+ type: 'oauth2'
+ // No accessToken, no flow - should make request without auth header
}
});
- throw new Error('Expected request to throw since implicit flow requires redirect');
+ throw new Error('Expected to fail');
} catch (error) {
- // Verify that the response_type was included correctly
- const url = new URL(capturedAuthUrl);
- expect(url.searchParams.get('response_type')).toBe(RESPONSE_TYPE);
+ expect(error.message).toBe('Unauthorized');
}
});
});
});
-});
\ No newline at end of file
+});
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 7fa6ea1e..1e4b4c50 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
@@ -11,7 +11,7 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
it('should authenticate with OAuth2 Password flow', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const CLIENT_ID = 'test-client-id';
@@ -27,22 +27,22 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
if (req.body.grant_type !== 'password') {
return res.status(400).json(TestResponses.badRequest('Invalid grant type').body);
}
-
+
// Validate client ID
if (req.body.client_id !== CLIENT_ID) {
return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body);
}
-
+
// Validate client secret if provided
if (CLIENT_SECRET && req.body.client_secret !== CLIENT_SECRET) {
return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body);
}
-
+
// Validate username and password
if (req.body.username !== USERNAME || req.body.password !== PASSWORD) {
return res.status(401).json(TestResponses.unauthorized('Invalid username or password').body);
}
-
+
// Return a successful token response
res.json(createTokenResponse({
accessToken: ACCESS_TOKEN,
@@ -50,33 +50,34 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
expiresIn: 3600
}));
});
-
+
// Protected API endpoint that requires Bearer token
router.post('/ping', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body);
}
-
+
// Validate the Bearer token
const token = authHeader.split(' ')[1];
if (token !== ACCESS_TOKEN) {
return res.status(401).json(TestResponses.unauthorized('Invalid Token').body);
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
// Mock onTokenRefresh callback
const onTokenRefresh = jest.fn();
-
- const receivedReplyMessage = await postPingPostRequest({
+
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'password',
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
@@ -86,22 +87,22 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
onTokenRefresh
}
});
-
+
// Verify that the token refresh callback was called with the expected tokens
expect(onTokenRefresh).toHaveBeenCalledWith({
accessToken: ACCESS_TOKEN,
refreshToken: REFRESH_TOKEN,
expiresIn: 3600
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should handle invalid username/password', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const CLIENT_ID = 'test-client-id';
const INVALID_USERNAME = 'wronguser';
@@ -110,18 +111,19 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
// Mock token endpoint
router.post('/oauth/token', (req, res) => {
// Always return invalid grant for this test
- res.status(401).json({
- error: 'invalid_grant',
- error_description: 'Invalid username or password'
+ res.status(401).json({
+ error: 'invalid_grant',
+ error_description: 'Invalid username or password'
});
});
-
+
return runWithServer(app, port, async () => {
try {
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'password',
clientId: CLIENT_ID,
username: INVALID_USERNAME,
@@ -135,19 +137,20 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
}
});
});
-
+
it('should handle missing clientId in password flow', async () => {
const port = Math.floor(Math.random() * (9875 - 5779 + 1)) + 5779;
const requestMessage = new Ping({});
const USERNAME = 'testuser';
const PASSWORD = 'testpassword';
-
+
try {
// Using as any to bypass TypeScript's type checking for this test
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'password',
username: USERNAME,
password: PASSWORD,
@@ -159,11 +162,11 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
expect(error.message).toBe('OAuth2 Password flow requires clientId');
}
});
-
+
it('should handle password flow with scopes', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const CLIENT_ID = 'test-client-id';
@@ -179,40 +182,41 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
if (req.body.grant_type !== 'password') {
return res.status(400).json(TestResponses.badRequest('Invalid grant type').body);
}
-
+
// Store the received scopes for later verification
receivedScopes = req.body.scope;
-
+
// Return a successful token response
res.json(createTokenResponse({
accessToken: ACCESS_TOKEN,
expiresIn: 3600
}));
});
-
+
// Protected API endpoint
router.post('/ping', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body);
}
-
+
// Validate the Bearer token
const token = authHeader.split(' ')[1];
if (token !== ACCESS_TOKEN) {
return res.status(401).json(TestResponses.unauthorized('Invalid Token').body);
}
-
+
res.setHeader('Content-Type', 'application/json');
res.write(replyMessage.marshal());
res.end();
});
-
+
return runWithServer(app, port, async () => {
- const receivedReplyMessage = await postPingPostRequest({
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
flow: 'password',
clientId: CLIENT_ID,
username: USERNAME,
@@ -221,11 +225,11 @@ describe('HTTP Client - OAuth2 Password Flow', () => {
tokenUrl: `http://localhost:${port}/oauth/token`
}
});
-
+
// Verify that the scopes were sent correctly
expect(receivedScopes).toBe(SCOPES.join(' '));
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
});
-});
\ No newline at end of file
+});
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 2963546b..7d6c78f4 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
@@ -11,7 +11,7 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
it('should refresh token when receiving 401 with an expired token', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const CLIENT_ID = 'test-client-id';
@@ -28,23 +28,23 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
if (req.body.grant_type !== 'refresh_token') {
return res.status(400).json(TestResponses.badRequest('Invalid grant type').body);
}
-
+
// Validate refresh token
if (req.body.refresh_token !== REFRESH_TOKEN) {
return res.status(401).json(TestResponses.unauthorized('Invalid refresh token').body);
}
-
+
// Validate client credentials
if (req.body.client_id !== CLIENT_ID) {
return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body);
}
-
+
if (CLIENT_SECRET && req.body.client_secret !== CLIENT_SECRET) {
return res.status(401).json(TestResponses.unauthorized('Invalid client credentials').body);
}
-
+
tokenRefreshCalled = true;
-
+
// Return a successful token response with a new access token and refresh token
res.json(createTokenResponse({
accessToken: NEW_ACCESS_TOKEN,
@@ -52,7 +52,7 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
expiresIn: 3600
}));
});
-
+
// Protected API endpoint that requires Bearer token
// First request will return 401, second request with refreshed token will succeed
let requestCount = 0;
@@ -61,16 +61,16 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(TestResponses.unauthorized('Bearer Authentication Required').body);
}
-
+
// Get the token
const token = authHeader.split(' ')[1];
-
+
// First request with expired token returns 401
if (requestCount === 0 && token === EXPIRED_ACCESS_TOKEN) {
requestCount++;
return res.status(401).json(TestResponses.unauthorized('Token Expired').body);
}
-
+
// Second request with new token should succeed
if (requestCount === 1 && token === NEW_ACCESS_TOKEN) {
requestCount++;
@@ -79,19 +79,20 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
res.end();
return;
}
-
+
// Unexpected token
res.status(401).json(TestResponses.unauthorized('Invalid Token').body);
});
-
+
return runWithServer(app, port, async () => {
// Mock onTokenRefresh callback
const onTokenRefresh = jest.fn();
-
- const receivedReplyMessage = await postPingPostRequest({
+
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
accessToken: EXPIRED_ACCESS_TOKEN,
@@ -100,27 +101,27 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
onTokenRefresh
}
});
-
+
// Verify that token refresh was called
expect(tokenRefreshCalled).toBe(true);
-
+
// Verify that the token refresh callback was called with the expected tokens
expect(onTokenRefresh).toHaveBeenCalledWith({
accessToken: NEW_ACCESS_TOKEN,
refreshToken: NEW_REFRESH_TOKEN,
expiresIn: 3600
});
-
+
// Verify that the request succeeded with the refreshed token
expect(requestCount).toBe(2);
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
-
+
it('should handle refresh token errors', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const CLIENT_ID = 'test-client-id';
const EXPIRED_ACCESS_TOKEN = 'expired-token-12345';
@@ -130,19 +131,20 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
router.post('/oauth/token', (req, res) => {
res.status(401).json(TestResponses.unauthorized('Invalid refresh token').body);
});
-
+
// Protected API endpoint that requires Bearer token
router.post('/ping', (req, res) => {
// Always return 401 for this test
res.status(401).json(TestResponses.unauthorized('Token Expired').body);
});
-
+
return runWithServer(app, port, async () => {
try {
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
clientId: CLIENT_ID,
accessToken: EXPIRED_ACCESS_TOKEN,
refreshToken: INVALID_REFRESH_TOKEN,
@@ -156,11 +158,11 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
}
});
});
-
+
it('should handle refresh token with missing required parameters', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const EXPIRED_ACCESS_TOKEN = 'expired-token-12345';
// Missing clientId which is required for refresh
@@ -169,13 +171,14 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
router.post('/ping', (req, res) => {
res.status(401).json(TestResponses.unauthorized('Token Expired').body);
});
-
+
return runWithServer(app, port, async () => {
try {
await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
accessToken: EXPIRED_ACCESS_TOKEN,
refreshToken: 'refresh-token',
tokenUrl: `http://localhost:${port}/oauth/token`
@@ -189,11 +192,11 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
}
});
});
-
+
it('should preserve original refresh token if new one not returned', async () => {
const { app, router, port } = createTestServer();
app.use(bodyParser.urlencoded({ extended: true }));
-
+
const requestMessage = new Ping({});
const replyMessage = new Pong({additionalProperties: new Map([['test', true]])});
const CLIENT_ID = 'test-client-id';
@@ -201,7 +204,7 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
const REFRESH_TOKEN = 'refresh-token-12345';
const NEW_ACCESS_TOKEN = 'new-access-token-12345';
// No new refresh token in the response
-
+
// Mock token endpoint for refresh
router.post('/oauth/token', (req, res) => {
// Return a successful token response with only a new access token
@@ -211,19 +214,19 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
// No refresh token
}));
});
-
+
// Protected API endpoint that requires Bearer token
let requestCount = 0;
router.post('/ping', (req, res) => {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];
-
+
// First request with expired token returns 401
if (requestCount === 0 && token === EXPIRED_ACCESS_TOKEN) {
requestCount++;
return res.status(401).json(TestResponses.unauthorized('Token Expired').body);
}
-
+
// Second request with new token should succeed
if (requestCount === 1 && token === NEW_ACCESS_TOKEN) {
requestCount++;
@@ -232,15 +235,16 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
res.end();
}
});
-
+
return runWithServer(app, port, async () => {
// Mock onTokenRefresh callback
const onTokenRefresh = jest.fn();
-
- const receivedReplyMessage = await postPingPostRequest({
+
+ const response = await postPingPostRequest({
payload: requestMessage,
server: `http://localhost:${port}`,
- oauth2: {
+ auth: {
+ type: 'oauth2',
clientId: CLIENT_ID,
accessToken: EXPIRED_ACCESS_TOKEN,
refreshToken: REFRESH_TOKEN,
@@ -248,7 +252,7 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
onTokenRefresh
}
});
-
+
// Verify that the token refresh callback was called with the new access token
// and the original refresh token preserved
expect(onTokenRefresh).toHaveBeenCalledWith({
@@ -256,9 +260,9 @@ describe('HTTP Client - OAuth2 Refresh Token Flow', () => {
refreshToken: REFRESH_TOKEN, // Original refresh token preserved
expiresIn: 3600
});
-
- expect(receivedReplyMessage?.marshal()).toEqual(replyMessage.marshal());
+
+ expect(response.data?.marshal()).toEqual(replyMessage.marshal());
});
});
});
-});
\ No newline at end of file
+});
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
new file mode 100644
index 00000000..ac8ed95a
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/pagination.spec.ts
@@ -0,0 +1,396 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ getPingGetRequest,
+ PaginationConfig,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - Pagination', () => {
+ describe('offset pagination', () => {
+ it('should add offset pagination params to query string', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedOffset: string | undefined;
+ let receivedLimit: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedOffset = req.query.offset as string;
+ receivedLimit = req.query.limit as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const pagination: PaginationConfig = {
+ type: 'offset',
+ offset: 20,
+ limit: 10
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination
+ });
+
+ expect(receivedOffset).toBe('20');
+ expect(receivedLimit).toBe('10');
+ });
+ });
+
+ it('should add pagination params to headers when in: header', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedOffsetHeader: string | undefined;
+ let receivedLimitHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedOffsetHeader = req.headers['x-offset'] as string;
+ receivedLimitHeader = req.headers['x-limit'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const pagination: PaginationConfig = {
+ type: 'offset',
+ in: 'header',
+ offset: 50,
+ limit: 25
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination
+ });
+
+ expect(receivedOffsetHeader).toBe('50');
+ expect(receivedLimitHeader).toBe('25');
+ });
+ });
+
+ it('should use custom pagination param names', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedSkip: string | undefined;
+ let receivedTake: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedSkip = req.query.skip as string;
+ receivedTake = req.query.take as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const pagination: PaginationConfig = {
+ type: 'offset',
+ offset: 100,
+ limit: 50,
+ offsetParam: 'skip',
+ limitParam: 'take'
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination
+ });
+
+ expect(receivedSkip).toBe('100');
+ expect(receivedTake).toBe('50');
+ });
+ });
+ });
+
+ describe('cursor pagination', () => {
+ it('should add cursor pagination params', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedCursor: string | undefined;
+ let receivedLimit: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedCursor = req.query.cursor as string;
+ receivedLimit = req.query.limit as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const pagination: PaginationConfig = {
+ type: 'cursor',
+ cursor: 'abc123xyz',
+ limit: 15
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination
+ });
+
+ expect(receivedCursor).toBe('abc123xyz');
+ expect(receivedLimit).toBe('15');
+ });
+ });
+
+ it('should handle cursor-based pagination with next cursor from headers', async () => {
+ const { app, router, port } = createTestServer();
+
+ const cursors: (string | undefined)[] = [];
+
+ router.get('/ping', (req, res) => {
+ const cursor = req.query.cursor as string | undefined;
+ cursors.push(cursor);
+
+ const replyMessage = new Pong({});
+ res.setHeader('Content-Type', 'application/json');
+
+ if (!cursor) {
+ res.setHeader('X-Next-Cursor', 'cursor-page-2');
+ } else if (cursor === 'cursor-page-2') {
+ res.setHeader('X-Next-Cursor', 'cursor-page-3');
+ }
+
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const page1 = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'cursor', limit: 10 }
+ });
+
+ expect(page1.pagination?.nextCursor).toBe('cursor-page-2');
+ expect(page1.hasNextPage?.()).toBe(true);
+
+ const page2 = await page1.getNextPage!();
+ expect(page2.pagination?.nextCursor).toBe('cursor-page-3');
+
+ const page3 = await page2.getNextPage!();
+ expect(page3.pagination?.nextCursor).toBeUndefined();
+ expect(page3.hasNextPage?.()).toBe(false);
+
+ expect(cursors).toEqual([undefined, 'cursor-page-2', 'cursor-page-3']);
+ });
+ });
+ });
+
+ describe('page pagination', () => {
+ it('should add page-based pagination params', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedPage: string | undefined;
+ let receivedPageSize: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedPage = req.query.page as string;
+ receivedPageSize = req.query.pageSize as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const pagination: PaginationConfig = {
+ type: 'page',
+ page: 3,
+ pageSize: 25
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination
+ });
+
+ expect(receivedPage).toBe('3');
+ expect(receivedPageSize).toBe('25');
+ });
+ });
+ });
+
+ describe('range pagination', () => {
+ it('should add Range header for range pagination', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let receivedRangeHeader: string | undefined;
+
+ router.get('/ping', (req, res) => {
+ receivedRangeHeader = req.headers['range'] as string;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const pagination: PaginationConfig = {
+ type: 'range',
+ start: 0,
+ end: 24,
+ unit: 'items'
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination
+ });
+
+ expect(receivedRangeHeader).toBe('items=0-24');
+ });
+ });
+
+ it('should handle range pagination for page navigation', async () => {
+ const { app, router, port } = createTestServer();
+
+ const ranges: string[] = [];
+
+ router.get('/ping', (req, res) => {
+ ranges.push(req.headers['range'] as string);
+ const replyMessage = new Pong({});
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '100');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const page1 = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'range', start: 0, end: 24, unit: 'items' }
+ });
+
+ expect(page1.hasNextPage?.()).toBe(true);
+
+ await page1.getNextPage!();
+
+ expect(ranges).toEqual(['items=0-24', 'items=25-49']);
+ });
+ });
+ });
+
+ describe('pagination helpers', () => {
+ it('should provide pagination helpers when pagination is configured', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+
+ router.get('/ping', (req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '100');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'offset', offset: 0, limit: 20 }
+ });
+
+ expect(response.hasNextPage).toBeDefined();
+ expect(response.hasPrevPage).toBeDefined();
+ expect(response.getNextPage).toBeDefined();
+ expect(response.getPrevPage).toBeDefined();
+ expect(response.hasNextPage?.()).toBe(true);
+ expect(response.hasPrevPage?.()).toBe(false);
+ });
+ });
+
+ it('should navigate through pages with getNextPage', async () => {
+ const { app, router, port } = createTestServer();
+
+ let requestCount = 0;
+ const offsets: string[] = [];
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ offsets.push(req.query.offset as string);
+ const replyMessage = new Pong({ additionalProperties: new Map([['page', requestCount]]) });
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '100');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const page1 = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'offset', offset: 0, limit: 20 }
+ });
+
+ expect(page1.hasNextPage?.()).toBe(true);
+
+ const page2 = await page1.getNextPage!();
+ expect(page2.pagination?.currentOffset).toBe(20);
+
+ const page3 = await page2.getNextPage!();
+ expect(page3.pagination?.currentOffset).toBe(40);
+
+ expect(requestCount).toBe(3);
+ expect(offsets).toEqual(['0', '20', '40']);
+ });
+ });
+
+ it('should navigate backwards with getPrevPage', async () => {
+ const { app, router, port } = createTestServer();
+
+ const offsets: string[] = [];
+
+ router.get('/ping', (req, res) => {
+ offsets.push(req.query.offset as string);
+ const replyMessage = new Pong({});
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Total-Count', '100');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const page = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'offset', offset: 60, limit: 20 }
+ });
+
+ expect(page.hasPrevPage?.()).toBe(true);
+
+ const prevPage = await page.getPrevPage!();
+ expect(prevPage.pagination?.currentOffset).toBe(40);
+
+ expect(offsets).toEqual(['60', '40']);
+ });
+ });
+
+ it('should parse Link header for pagination info', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/ping', (req, res) => {
+ const replyMessage = new Pong({});
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Link', '; rel="next", ; rel="last"');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ pagination: { type: 'page', page: 1, pageSize: 20 }
+ });
+
+ expect(response.pagination?.hasMore).toBe(true);
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000..23da52e2
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/parameters-headers.spec.ts
@@ -0,0 +1,385 @@
+/* eslint-disable no-console */
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ getGetUserItem,
+ putUpdateUserItem,
+} from '../../../../src/request-reply/channels/http_client';
+import { UserItemsParameters } from "../../../../src/request-reply/parameters/UserItemsParameters";
+import { ItemRequestHeaders } from "../../../../src/request-reply/headers/ItemRequestHeaders";
+import { ItemRequest } from "../../../../src/request-reply/payloads/ItemRequest";
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - Parameters and Headers', () => {
+ describe('path parameters', () => {
+ it('should replace path parameters in URL', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedPath: string | undefined;
+
+ router.get('/users/:userId/items/:itemId', (req, res) => {
+ receivedPath = req.path;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: 'Test Item',
+ quantity: 5
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const parameters = new UserItemsParameters({
+ userId: 'user-123',
+ itemId: '456'
+ });
+
+ const response = await getGetUserItem({
+ server: `http://localhost:${port}`,
+ parameters
+ });
+
+ expect(receivedPath).toBe('/users/user-123/items/456');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should work with different parameter values', async () => {
+ const { app, router, port } = createTestServer();
+
+ const receivedParams: { userId: string; itemId: string }[] = [];
+
+ router.get('/users/:userId/items/:itemId', (req, res) => {
+ receivedParams.push({
+ userId: req.params.userId,
+ itemId: req.params.itemId
+ });
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: 'Item',
+ quantity: 1
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ await getGetUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'alice', itemId: '100' })
+ });
+
+ await getGetUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'bob', itemId: '200' })
+ });
+
+ expect(receivedParams).toEqual([
+ { userId: 'alice', itemId: '100' },
+ { userId: 'bob', itemId: '200' }
+ ]);
+ });
+ });
+
+ it('should combine parameters with authentication', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedPath: string | undefined;
+ let receivedAuthHeader: string | undefined;
+
+ router.get('/users/:userId/items/:itemId', (req, res) => {
+ receivedPath = req.path;
+ receivedAuthHeader = req.headers.authorization;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: 'Secure Item',
+ quantity: 10
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await getGetUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'secure-user', itemId: '999' }),
+ auth: { type: 'bearer', token: 'secret-token' }
+ });
+
+ expect(receivedPath).toBe('/users/secure-user/items/999');
+ expect(receivedAuthHeader).toBe('Bearer secret-token');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should combine parameters with query params', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedPath: string | undefined;
+ let receivedQueryInclude: string | undefined;
+ let receivedQueryFields: string | undefined;
+
+ router.get('/users/:userId/items/:itemId', (req, res) => {
+ receivedPath = req.path;
+ receivedQueryInclude = req.query.include as string;
+ receivedQueryFields = req.query.fields as string;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: 'Item with metadata',
+ quantity: 1
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ await getGetUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'user1', itemId: '42' }),
+ queryParams: {
+ include: 'metadata',
+ fields: 'name,quantity'
+ }
+ });
+
+ expect(receivedPath).toBe('/users/user1/items/42');
+ expect(receivedQueryInclude).toBe('metadata');
+ expect(receivedQueryFields).toBe('name,quantity');
+ });
+ });
+ });
+
+ describe('typed headers', () => {
+ it('should send typed headers in request', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedCorrelationId: string | undefined;
+ let receivedRequestId: string | undefined;
+
+ router.put('/users/:userId/items/:itemId', (req, res) => {
+ receivedCorrelationId = req.headers['x-correlation-id'] as string;
+ receivedRequestId = req.headers['x-request-id'] as string;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: req.body.name,
+ description: req.body.description,
+ quantity: req.body.quantity
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const headers = new ItemRequestHeaders({
+ xCorrelationId: 'corr-123-abc',
+ xRequestId: 'req-456-def'
+ });
+
+ const payload = new ItemRequest({
+ name: 'Updated Item',
+ description: 'New description',
+ quantity: 25
+ });
+
+ const response = await putUpdateUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'user-1', itemId: '100' }),
+ payload,
+ requestHeaders: headers
+ });
+
+ expect(receivedCorrelationId).toBe('corr-123-abc');
+ expect(receivedRequestId).toBe('req-456-def');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should work with only required headers', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedCorrelationId: string | undefined;
+ let receivedRequestId: string | undefined;
+
+ router.put('/users/:userId/items/:itemId', (req, res) => {
+ receivedCorrelationId = req.headers['x-correlation-id'] as string;
+ receivedRequestId = req.headers['x-request-id'] as string;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: req.body.name,
+ quantity: req.body.quantity
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const headers = new ItemRequestHeaders({
+ xCorrelationId: 'required-only'
+ });
+
+ const payload = new ItemRequest({
+ name: 'Minimal Item'
+ });
+
+ const response = await putUpdateUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'u1', itemId: '1' }),
+ payload,
+ requestHeaders: headers
+ });
+
+ expect(receivedCorrelationId).toBe('required-only');
+ expect(receivedRequestId).toBeUndefined();
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should merge typed headers with additional headers', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedCorrelationId: string | undefined;
+ let receivedCustomHeader: string | undefined;
+
+ router.put('/users/:userId/items/:itemId', (req, res) => {
+ receivedCorrelationId = req.headers['x-correlation-id'] as string;
+ receivedCustomHeader = req.headers['x-custom-header'] as string;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: req.body.name,
+ quantity: 1
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await putUpdateUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'u', itemId: '1' }),
+ payload: new ItemRequest({ name: 'Item' }),
+ requestHeaders: new ItemRequestHeaders({ xCorrelationId: 'corr-id' }),
+ additionalHeaders: {
+ 'X-Custom-Header': 'custom-value'
+ }
+ });
+
+ expect(receivedCorrelationId).toBe('corr-id');
+ expect(receivedCustomHeader).toBe('custom-value');
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should combine typed headers with auth', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedCorrelationId: string | undefined;
+ let receivedAuthHeader: string | undefined;
+
+ router.put('/users/:userId/items/:itemId', (req, res) => {
+ receivedCorrelationId = req.headers['x-correlation-id'] as string;
+ receivedAuthHeader = req.headers.authorization;
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: req.body.name,
+ quantity: 1
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ await putUpdateUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'u', itemId: '1' }),
+ payload: new ItemRequest({ name: 'Secure Item' }),
+ requestHeaders: new ItemRequestHeaders({ xCorrelationId: 'secure-corr' }),
+ auth: { type: 'bearer', token: 'auth-token' }
+ });
+
+ expect(receivedCorrelationId).toBe('secure-corr');
+ expect(receivedAuthHeader).toBe('Bearer auth-token');
+ });
+ });
+ });
+
+ describe('full integration', () => {
+ it('should handle full request with parameters, headers, payload, and auth', async () => {
+ const { app, router, port } = createTestServer();
+
+ let receivedData: {
+ path: string;
+ correlationId: string;
+ requestId?: string;
+ authHeader: string;
+ body: any;
+ query: any;
+ } | undefined;
+
+ router.put('/users/:userId/items/:itemId', (req, res) => {
+ receivedData = {
+ path: req.path,
+ correlationId: req.headers['x-correlation-id'] as string,
+ requestId: req.headers['x-request-id'] as string,
+ authHeader: req.headers.authorization as string,
+ body: req.body,
+ query: req.query
+ };
+
+ res.json({
+ id: req.params.itemId,
+ userId: req.params.userId,
+ name: req.body.name,
+ description: req.body.description,
+ quantity: req.body.quantity
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const response = await putUpdateUserItem({
+ server: `http://localhost:${port}`,
+ parameters: new UserItemsParameters({ userId: 'full-user', itemId: '999' }),
+ payload: new ItemRequest({
+ name: 'Complete Item',
+ description: 'Full test',
+ quantity: 100
+ }),
+ requestHeaders: new ItemRequestHeaders({
+ xCorrelationId: 'full-corr-id',
+ xRequestId: 'full-req-id'
+ }),
+ auth: { type: 'basic', username: 'admin', password: 'secret' },
+ queryParams: { verbose: 'true' }
+ });
+
+ expect(receivedData).toBeDefined();
+ expect(receivedData?.path).toBe('/users/full-user/items/999');
+ expect(receivedData?.correlationId).toBe('full-corr-id');
+ expect(receivedData?.requestId).toBe('full-req-id');
+ expect(receivedData?.authHeader).toContain('Basic');
+ expect(receivedData?.body.name).toBe('Complete Item');
+ expect(receivedData?.query.verbose).toBe('true');
+ expect(response.status).toBe(200);
+ });
+ });
+
+ it('should handle 404 response with parameters', async () => {
+ const { app, router, port } = createTestServer();
+
+ router.get('/users/:userId/items/:itemId', (req, res) => {
+ res.status(404).json({
+ error: 'Item not found',
+ code: 'ITEM_NOT_FOUND'
+ });
+ });
+
+ return runWithServer(app, port, async () => {
+ const parameters = new UserItemsParameters({
+ userId: 'user-1',
+ itemId: 'non-existent'
+ });
+
+ try {
+ const response = await getGetUserItem({
+ server: `http://localhost:${port}`,
+ parameters
+ });
+ expect(response.status).toBe(404);
+ } catch (error) {
+ expect((error as Error).message).toContain('Not Found');
+ }
+ });
+ });
+ });
+});
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
new file mode 100644
index 00000000..23383fa8
--- /dev/null
+++ b/test/runtime/typescript/test/channels/request_reply/http_client/retry.spec.ts
@@ -0,0 +1,452 @@
+/* eslint-disable no-console */
+import { Pong } from "../../../../src/request-reply/payloads/Pong";
+import { createTestServer, runWithServer } from './test-utils';
+import {
+ getPingGetRequest,
+ RetryConfig,
+} from '../../../../src/request-reply/channels/http_client';
+
+jest.setTimeout(15000);
+
+describe('HTTP Client - Retry Logic', () => {
+ describe('basic retry behavior', () => {
+ it('should retry on 500 error and succeed', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 3) {
+ res.status(500).json({ error: 'Server Error' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 100,
+ retryableStatusCodes: [500]
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(requestCount).toBe(3);
+ expect(response.data.marshal()).toEqual(replyMessage.marshal());
+ });
+ });
+
+ it('should retry multiple times before success', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ const retryCalls: number[] = [];
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 4) {
+ res.status(500).json({ error: 'Server Error' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 5,
+ initialDelayMs: 30,
+ retryableStatusCodes: [500],
+ onRetry: (attempt) => {
+ retryCalls.push(attempt);
+ }
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(requestCount).toBe(4);
+ expect(retryCalls).toEqual([1, 2, 3]);
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+
+ describe('retry callbacks', () => {
+ it('should call onRetry callback', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ const retryCalls: { attempt: number; delay: number }[] = [];
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(503).json({ error: 'Service Unavailable' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50,
+ retryableStatusCodes: [503],
+ onRetry: (attempt, delay) => {
+ retryCalls.push({ attempt, delay });
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(retryCalls.length).toBe(1);
+ expect(retryCalls[0].attempt).toBe(1);
+ });
+ });
+ });
+
+ describe('max retries exhausted', () => {
+ it('should fail after max retries exhausted', async () => {
+ const { app, router, port } = createTestServer();
+
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ res.status(500).json({ error: 'Server Error' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50,
+ retryableStatusCodes: [500]
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ })).rejects.toThrow('Internal Server Error');
+
+ expect(requestCount).toBe(3);
+ });
+ });
+ });
+
+ describe('exponential backoff', () => {
+ it('should apply exponential backoff', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ const delays: number[] = [];
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 4) {
+ res.status(502).json({ error: 'Bad Gateway' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 5,
+ initialDelayMs: 100,
+ backoffMultiplier: 2,
+ retryableStatusCodes: [502],
+ onRetry: (attempt, delay) => {
+ delays.push(delay);
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(delays.length).toBe(3);
+ expect(delays[0]).toBe(100);
+ expect(delays[1]).toBe(200);
+ expect(delays[2]).toBe(400);
+ });
+ });
+
+ it('should respect maxDelayMs cap', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+ const delays: number[] = [];
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 5) {
+ res.status(503).json({ error: 'Service Unavailable' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 5,
+ initialDelayMs: 100,
+ maxDelayMs: 250,
+ backoffMultiplier: 2,
+ retryableStatusCodes: [503],
+ onRetry: (attempt, delay) => {
+ delays.push(delay);
+ }
+ };
+
+ await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(delays.every(d => d <= 250)).toBe(true);
+ expect(delays[3]).toBe(250);
+ });
+ });
+ });
+
+ describe('retryable status codes', () => {
+ it('should not retry non-retryable status codes', async () => {
+ const { app, router, port } = createTestServer();
+
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ res.status(400).json({ error: 'Bad Request' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50,
+ retryableStatusCodes: [500, 502, 503]
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ })).rejects.toThrow();
+
+ expect(requestCount).toBe(1);
+ });
+ });
+
+ it('should retry on 429 rate limit with default config', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(429).json({ error: 'Too Many Requests' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(requestCount).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should not retry when retryableStatusCodes is empty', async () => {
+ const { app, router, port } = createTestServer();
+
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ res.status(500).json({ error: 'Server Error' });
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50,
+ retryableStatusCodes: []
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ })).rejects.toThrow('Internal Server Error');
+
+ expect(requestCount).toBe(1);
+ });
+ });
+
+ it('should retry on 408 Request Timeout', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(408).json({ error: 'Request Timeout' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(requestCount).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ it('should retry on 504 Gateway Timeout', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ if (requestCount < 2) {
+ res.status(504).json({ error: 'Gateway Timeout' });
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ }
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50
+ };
+
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(requestCount).toBe(2);
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+
+ describe('network errors', () => {
+ it('should fail immediately on network error without retry config', async () => {
+ // Use a port that's not listening to simulate network error
+ const unusedPort = 59999;
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${unusedPort}`
+ })).rejects.toThrow();
+ });
+
+ it('should retry on network error and eventually fail', async () => {
+ const unusedPort = 59998;
+ const retryCalls: number[] = [];
+
+ const retry: RetryConfig = {
+ maxRetries: 2,
+ initialDelayMs: 50,
+ onRetry: (attempt) => {
+ retryCalls.push(attempt);
+ }
+ };
+
+ await expect(getPingGetRequest({
+ server: `http://localhost:${unusedPort}`,
+ retry
+ })).rejects.toThrow();
+
+ // Network errors should trigger retries
+ expect(retryCalls.length).toBeGreaterThan(0);
+ });
+
+ it('should recover after network error when server becomes available', async () => {
+ const { app, router, port } = createTestServer();
+
+ const replyMessage = new Pong({});
+ let requestCount = 0;
+
+ router.get('/ping', (req, res) => {
+ requestCount++;
+ res.setHeader('Content-Type', 'application/json');
+ res.write(replyMessage.marshal());
+ res.end();
+ });
+
+ return runWithServer(app, port, async () => {
+ const retry: RetryConfig = {
+ maxRetries: 3,
+ initialDelayMs: 50
+ };
+
+ // Request should succeed on first try
+ const response = await getPingGetRequest({
+ server: `http://localhost:${port}`,
+ retry
+ });
+
+ expect(requestCount).toBe(1);
+ expect(response.data).toBeDefined();
+ });
+ });
+ });
+});