import * as plugins from './bunq.plugins.js'; import { BunqCrypto } from './bunq.classes.crypto.js'; import type { IBunqApiContext, IBunqError, IBunqRequestOptions } from './bunq.interfaces.js'; export class BunqHttpClient { private crypto: BunqCrypto; private context: IBunqApiContext; private requestCounter: number = 0; constructor(crypto: BunqCrypto, context: IBunqApiContext) { this.crypto = crypto; this.context = context; } /** * Update the API context (used after getting session token) */ public updateContext(context: Partial): void { this.context = { ...this.context, ...context }; } /** * Make an API request to bunq with automatic retry on rate limit */ public async request(options: IBunqRequestOptions): Promise { const maxRetries = 3; let lastError: Error; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await this.makeRequest(options); } catch (error) { lastError = error as Error; // Check if it's a rate limit error if (error instanceof BunqApiError) { const isRateLimitError = error.errors.some(e => e.error_description.includes('Too many requests') || e.error_description.includes('rate limit') ); if (isRateLimitError && attempt < maxRetries) { // Exponential backoff: 1s, 2s, 4s const backoffMs = Math.pow(2, attempt) * 1000; console.log(`Rate limit hit, backing off for ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`); await new Promise(resolve => setTimeout(resolve, backoffMs)); continue; } } // For non-rate-limit errors or if we've exhausted retries, throw immediately throw error; } } throw lastError!; } /** * Internal method to make the actual request */ private async makeRequest(options: IBunqRequestOptions): Promise { let url = `${this.context.baseUrl}${options.endpoint}`; // Prepare headers const headers = this.prepareHeaders(options); // Prepare body const body = options.body ? JSON.stringify(options.body) : undefined; // Add signature if required if (options.useSigning !== false && this.crypto.getPrivateKey()) { headers['X-Bunq-Client-Signature'] = this.crypto.createSignatureHeader( options.method, options.endpoint, headers, body || '' ); } // Handle query parameters if (options.params) { const queryParams = new URLSearchParams(); Object.entries(options.params).forEach(([key, value]) => { if (value !== undefined && value !== null) { queryParams.append(key, String(value)); } }); const queryString = queryParams.toString(); if (queryString) { url += '?' + queryString; } } // Make the request using native fetch const fetchOptions: RequestInit = { method: options.method === 'LIST' ? 'GET' : options.method, headers: headers, body: body }; try { const response = await fetch(url, fetchOptions); // Get response body as text const responseText = await response.text(); // Verify response signature if we have server public key if (this.context.serverPublicKey) { // Convert headers to string-only format const stringHeaders: { [key: string]: string } = {}; response.headers.forEach((value, key) => { stringHeaders[key] = value; }); const isValid = this.crypto.verifyResponseSignature( response.status, stringHeaders, responseText, this.context.serverPublicKey ); // For now, only enforce signature verification for payment-related endpoints // TODO: Fix signature verification for all endpoints const paymentEndpoints = ['/v1/payment', '/v1/payment-batch', '/v1/draft-payment']; const isPaymentEndpoint = paymentEndpoints.some(ep => options.endpoint.startsWith(ep)); if (!isValid && isPaymentEndpoint) { throw new Error('Invalid response signature'); } } // Parse response let responseData; if (responseText) { try { responseData = JSON.parse(responseText); } catch (parseError) { // If parsing fails and it's not a 2xx response, throw an HTTP error if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } throw new Error(`Failed to parse JSON response: ${parseError.message}`); } } else { // Empty response body responseData = {}; } // Check for errors if (responseData.Error) { throw new BunqApiError(responseData.Error); } // Check HTTP status if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return responseData; } catch (error) { if (error instanceof BunqApiError) { throw error; } // Handle network errors let errorMessage = 'Request failed: '; if (error instanceof Error) { errorMessage += error.message; } else if (typeof error === 'string') { errorMessage += error; } else { errorMessage += JSON.stringify(error); } throw new Error(errorMessage); } } /** * Prepare headers for the request */ private prepareHeaders(options: IBunqRequestOptions): { [key: string]: string } { const headers: { [key: string]: string } = { 'Cache-Control': 'no-cache', 'User-Agent': 'bunq-api-client/1.0.0', 'X-Bunq-Language': 'en_US', 'X-Bunq-Region': 'nl_NL', 'X-Bunq-Client-Request-Id': this.crypto.generateRequestId(), 'X-Bunq-Geolocation': '0 0 0 0 NL', 'Content-Type': 'application/json' }; // Add authentication token if (options.useSessionToken !== false) { if (this.context.sessionToken) { headers['X-Bunq-Client-Authentication'] = this.context.sessionToken; } else if (this.context.installationToken && options.endpoint !== '/v1/installation') { headers['X-Bunq-Client-Authentication'] = this.context.installationToken; } } return headers; } /** * LIST request helper */ public async list(endpoint: string, params?: any): Promise { return this.request({ method: 'LIST', endpoint, params }); } /** * GET request helper */ public async get(endpoint: string): Promise { return this.request({ method: 'GET', endpoint }); } /** * POST request helper */ public async post(endpoint: string, body?: any): Promise { return this.request({ method: 'POST', endpoint, body }); } /** * PUT request helper */ public async put(endpoint: string, body?: any): Promise { return this.request({ method: 'PUT', endpoint, body }); } /** * DELETE request helper */ public async delete(endpoint: string): Promise { return this.request({ method: 'DELETE', endpoint }); } } /** * Custom error class for bunq API errors */ export class BunqApiError extends Error { public errors: Array<{ error_description: string; error_description_translated: string; }>; constructor(errors: Array) { const message = errors.map(e => e.error_description).join('; '); super(message); this.name = 'BunqApiError'; this.errors = errors; } }