All files / src rpc-client.ts

85.29% Statements 29/34
70% Branches 14/20
66.66% Functions 2/3
87.87% Lines 29/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132                                                        63x 63x 63x 63x 63x 63x               63x             14x 14x   14x                       14x 14x 14x 7x 7x 7x 6x       14x             14x 14x       14x 14x           14x                       14x     14x 2x               12x   12x                 12x        
import { DEFAULT_BASE_URL, USER_AGENT } from './consts';
import { STACKONE_HEADER_KEYS } from './headers';
import {
	type RpcActionRequest,
	type RpcActionResponse,
	type RpcClientConfig,
	rpcActionRequestSchema,
	rpcActionResponseSchema,
	rpcClientConfigSchema,
} from './schema';
import { StackOneAPIError } from './utils/error-stackone-api';
 
// Re-export types for consumers and to make types portable
export type { RpcActionResponse } from './schema';
 
/**
 * Custom RPC client for StackOne API.
 * Replaces the @stackone/stackone-client-ts dependency.
 *
 * @see https://docs.stackone.com/platform/api-reference/actions/list-all-actions-metadata
 * @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action
 */
export class RpcClient {
	private readonly baseUrl: string;
	private readonly authHeader: string;
	private readonly timeout: number;
 
	constructor(config: RpcClientConfig) {
		const validatedConfig = rpcClientConfigSchema.parse(config);
		this.baseUrl = validatedConfig.serverURL || DEFAULT_BASE_URL;
		const username = validatedConfig.security.username;
		const password = validatedConfig.security.password || '';
		this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
		this.timeout = validatedConfig.timeout ?? 60_000;
	}
 
	/**
	 * Actions namespace containing RPC methods
	 */
	readonly actions: {
		rpcAction: (request: RpcActionRequest) => Promise<RpcActionResponse>;
	} = {
		/**
		 * Execute an RPC action
		 * @param request The RPC action request
		 * @returns The RPC action response matching server's ActionsRpcResponseApiModel
		 */
		rpcAction: async (request: RpcActionRequest): Promise<RpcActionResponse> => {
			const validatedRequest = rpcActionRequestSchema.parse(request);
			const url = `${this.baseUrl}/actions/rpc`;
 
			const requestBody = {
				action: validatedRequest.action,
				body: validatedRequest.body,
				...(validatedRequest.defender_config !== undefined && {
					defender_config: validatedRequest.defender_config,
				}),
				headers: validatedRequest.headers,
				path: validatedRequest.path,
				query: validatedRequest.query,
			} satisfies RpcActionRequest;
 
			// Forward StackOne-specific headers as HTTP headers
			const requestHeaders = validatedRequest.headers;
			const forwardedHeaders: Record<string, string> = {};
			if (requestHeaders) {
				for (const key of STACKONE_HEADER_KEYS) {
					const value = requestHeaders[key];
					if (value !== undefined) {
						forwardedHeaders[key] = value;
					}
				}
			}
			const httpHeaders = {
				'Content-Type': 'application/json',
				Authorization: this.authHeader,
				'User-Agent': USER_AGENT,
				...forwardedHeaders,
			} satisfies Record<string, string>;
 
			const controller = new AbortController();
			const timeoutId = setTimeout(() => controller.abort(), this.timeout);
 
			let response: Response;
			let responseBody: unknown;
			try {
				response = await fetch(url, {
					method: 'POST',
					headers: httpHeaders,
					body: JSON.stringify(requestBody),
					signal: controller.signal,
				});
				responseBody = await response.json();
			} catch (error) {
				if (error instanceof Error && error.name === 'AbortError') {
					throw new StackOneAPIError(
						`Request timed out after ${this.timeout}ms for ${url}`,
						0,
						null,
						requestBody,
					);
				}
				throw error;
			} finally {
				clearTimeout(timeoutId);
			}
 
			if (!response.ok) {
				throw new StackOneAPIError(
					`RPC action failed for ${url}`,
					response.status,
					responseBody,
					requestBody,
				);
			}
 
			const validation = rpcActionResponseSchema.safeParse(responseBody);
 
			Iif (!validation.success) {
				throw new StackOneAPIError(
					`Invalid RPC action response for ${url}`,
					response.status,
					responseBody,
					requestBody,
				);
			}
 
			return validation.data;
		},
	};
}