All files / src rpc-client.ts

90.24% Statements 37/41
82.14% Branches 23/28
50% Functions 2/4
94.87% Lines 37/39

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 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162                                                                  68x 68x 68x 68x 68x 68x               68x                     19x 19x   19x                       19x 19x 19x 7x 7x 7x 6x       19x             19x 19x       19x 19x                         19x 19x 3x 2x   1x 1x               16x   1x               1x   19x     16x 2x               14x   14x                 14x        
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 {
	type BinaryDownloadResult,
	binaryDownloadFromResponse,
	isJsonContentType,
} from './utils/binary-response';
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 | BinaryDownloadResult>;
	} = {
		/**
		 * Execute an RPC action
		 * @param request The RPC action request
		 * @returns The RPC action response matching server's ActionsRpcResponseApiModel, or - for a
		 *   file-download action served as raw binary - a {@link BinaryDownloadResult} of bytes +
		 *   metadata. `content` is a raw `Buffer` (not a `JsonValue`); handle it before re-serializing.
		 */
		rpcAction: async (
			request: RpcActionRequest,
		): Promise<RpcActionResponse | BinaryDownloadResult> => {
			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,
				});
 
				// A non-JSON body is never the {data,next} envelope. A successful one is a file download
				// (raw binary with the file's own MIME type + Content-Disposition) - e.g. a
				// *_unified_download_file action - returned as bytes + metadata. A non-JSON error body
				// (e.g. an HTML gateway error) is surfaced as a StackOneAPIError rather than letting
				// response.json() throw a raw SyntaxError. JSON bodies fall through to the parse +
				// envelope-validation path below.
				const contentType = response.headers.get('content-type') ?? '';
				if (!isJsonContentType(contentType)) {
					if (response.ok) {
						return await binaryDownloadFromResponse(response);
					}
					const errorText = await response.text().catch(() => null);
					throw new StackOneAPIError(
						`RPC action failed for ${url}`,
						response.status,
						errorText || null,
						requestBody,
					);
				}
 
				responseBody = await response.json();
			} catch (error) {
				Iif (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;
		},
	};
}