278 lines
7.7 KiB
TypeScript
278 lines
7.7 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 with automatic retry on rate limit
|
|
*/
|
|
public async request<T = any>(options: IBunqRequestOptions): Promise<T> {
|
|
const maxRetries = 3;
|
|
let lastError: Error;
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
return await this.makeRequest<T>(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<T = any>(options: IBunqRequestOptions): Promise<T> {
|
|
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<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;
|
|
}
|
|
} |