Files
bunq/ts/bunq.classes.httpclient.ts
Juergen Kunz 40f9142d70 feat(export): add buffer download methods to ExportBuilder
- Added download() method to get statements as Buffer without saving to disk
- Added downloadAsArrayBuffer() method for web API compatibility
- Enhanced documentation for getAccountStatement() method
- Updated README with comprehensive examples
- No breaking changes, backward compatible
2025-08-02 10:56:17 +00:00

268 lines
7.2 KiB
TypeScript

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<IBunqApiContext>): void {
this.context = { ...this.context, ...context };
}
/**
* Make an API request to bunq
*/
public async request<T = any>(options: IBunqRequestOptions): Promise<T> {
return this.makeRequest<T>(options);
}
/**
* Internal method to make the actual request
*/
private async makeRequest<T = any>(options: IBunqRequestOptions): Promise<T> {
const 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 || ''
);
}
// Create SmartRequest instance
const request = plugins.smartrequest.SmartRequest.create()
.url(url)
.handle429Backoff({
maxRetries: 3,
respectRetryAfter: true,
fallbackDelay: 1000,
backoffFactor: 2,
onRateLimit: (attempt, waitTime) => {
console.log(`Rate limit hit, backing off for ${waitTime}ms (attempt ${attempt}/4)`);
}
});
// Add headers
Object.entries(headers).forEach(([key, value]) => {
request.header(key, value);
});
// Add query parameters
if (options.params) {
request.query(options.params);
}
// Add body if present
if (body) {
request.json(JSON.parse(body));
}
try {
// Execute request based on method
let response: plugins.smartrequest.ICoreResponse; // Response type from SmartRequest
const method = options.method === 'LIST' ? 'GET' : options.method;
switch (method) {
case 'GET':
response = await request.get();
break;
case 'POST':
response = await request.post();
break;
case 'PUT':
response = await request.put();
break;
case 'DELETE':
response = await request.delete();
break;
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
// Get response body as text for signature verification
const responseText = await response.text();
// Verify response signature if we have server public key
if (this.context.serverPublicKey) {
const isValid = this.crypto.verifyResponseSignature(
response.status,
response.headers as { [key: string]: string },
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<T = any>(endpoint: string, params?: any): Promise<T> {
return this.request<T>({
method: 'LIST',
endpoint,
params
});
}
/**
* GET request helper
*/
public async get<T = any>(endpoint: string): Promise<T> {
return this.request<T>({
method: 'GET',
endpoint
});
}
/**
* POST request helper
*/
public async post<T = any>(endpoint: string, body?: any): Promise<T> {
return this.request<T>({
method: 'POST',
endpoint,
body
});
}
/**
* PUT request helper
*/
public async put<T = any>(endpoint: string, body?: any): Promise<T> {
return this.request<T>({
method: 'PUT',
endpoint,
body
});
}
/**
* DELETE request helper
*/
public async delete<T = any>(endpoint: string): Promise<T> {
return this.request<T>({
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<any>) {
const message = errors.map(e => e.error_description).join('; ');
super(message);
this.name = 'BunqApiError';
this.errors = errors;
}
}