diff --git a/src/__tests__/api/advancedWalletManager/keyProviderClient.test.ts b/src/__tests__/api/advancedWalletManager/keyProviderClient.test.ts index 2948e16f..418320bf 100644 --- a/src/__tests__/api/advancedWalletManager/keyProviderClient.test.ts +++ b/src/__tests__/api/advancedWalletManager/keyProviderClient.test.ts @@ -93,7 +93,7 @@ describe('postMpcV2Key', () => { response.status.should.equal(500); response.body.should.have.property('error', 'Internal Server Error'); - response.body.should.have.property('details', 'This is an error message'); + response.body.details.should.match(/This is an error message/); }); it('should handle unexpected key provider errors', async () => { @@ -106,10 +106,7 @@ describe('postMpcV2Key', () => { response.status.should.equal(500); response.body.should.have.property('error', 'Internal Server Error'); - response.body.should.have.property( - 'details', - 'key provider returned unexpected response. 502: Unexpected error', - ); + response.body.details.should.match(/502.*Unexpected error/); }); }); @@ -150,27 +147,17 @@ describe('KeyProviderClient.generateKey', () => { }); [ - { - url: endPointPath, - statusCode: 400, - mockedError: 'bad request', - expectedError: 'bad request', - }, - { url: endPointPath, statusCode: 404, mockedError: 'not found', expectedError: 'not found' }, - { url: endPointPath, statusCode: 409, mockedError: 'conflict', expectedError: 'conflict' }, - { - url: endPointPath, - statusCode: 500, - mockedError: 'internal error', - expectedError: 'internal error', - }, - ].forEach(({ url, statusCode, mockedError, expectedError }) => { + { url: endPointPath, statusCode: 400, mockedError: 'bad request' }, + { url: endPointPath, statusCode: 404, mockedError: 'not found' }, + { url: endPointPath, statusCode: 409, mockedError: 'conflict' }, + { url: endPointPath, statusCode: 500, mockedError: 'internal error' }, + ].forEach(({ url, statusCode, mockedError }) => { it(`should bubble up ${statusCode} errors`, async () => { const nockMocked = nock(keyProviderUrl) .post(url) .reply(statusCode, { message: mockedError }) .persist(); - await client.generateKey(params).should.be.rejectedWith(expectedError); + await client.generateKey(params).should.be.rejectedWith(new RegExp(mockedError)); nockMocked.done(); }); }); @@ -222,17 +209,17 @@ describe('KeyProviderClient.sign', () => { }); [ - { statusCode: 400, mockedError: 'bad request', expectedError: 'bad request' }, - { statusCode: 404, mockedError: 'not found', expectedError: 'not found' }, - { statusCode: 409, mockedError: 'conflict', expectedError: 'conflict' }, - { statusCode: 500, mockedError: 'internal error', expectedError: 'internal error' }, - ].forEach(({ statusCode, mockedError, expectedError }) => { + { statusCode: 400, mockedError: 'bad request' }, + { statusCode: 404, mockedError: 'not found' }, + { statusCode: 409, mockedError: 'conflict' }, + { statusCode: 500, mockedError: 'internal error' }, + ].forEach(({ statusCode, mockedError }) => { it(`should bubble up ${statusCode} errors`, async () => { const nockMocked = nock(keyProviderUrl) .post(endPointPath) .reply(statusCode, { message: mockedError }) .persist(); - await client.sign(params).should.be.rejectedWith(expectedError); + await client.sign(params).should.be.rejectedWith(new RegExp(mockedError)); nockMocked.done(); }); }); diff --git a/src/__tests__/api/master/bridgeClient.test.ts b/src/__tests__/api/master/bridgeClient.test.ts new file mode 100644 index 00000000..64e5d0e9 --- /dev/null +++ b/src/__tests__/api/master/bridgeClient.test.ts @@ -0,0 +1,331 @@ +import nock from 'nock'; +import 'should'; +import { OsoBridgeClient } from '../../../masterBitgoExpress/clients/bridgeClient'; +import { BridgeJobResponse } from '../../../masterBitgoExpress/clients/bridgeClient.types'; +import { KeySource } from '../../../shared/types'; + +const BASE_URL = 'http://bridge.invalid'; +const TIMEOUT = 60000; + +const mockJob: BridgeJobResponse = { + jobId: 'job-123', + status: 'awaiting_bitgo', + version: 1, + coin: 'tbtc', + operationType: 'multisig_sign', + createdAt: '2026-06-09T00:00:00.000Z', + updatedAt: '2026-06-09T00:00:00.000Z', +}; + +describe('OsoBridgeClient', () => { + let client: OsoBridgeClient; + + before(() => { + nock.disableNetConnect(); + client = new OsoBridgeClient(BASE_URL, TIMEOUT); + }); + + afterEach(() => nock.cleanAll()); + + describe('constructor', () => { + it('throws when url is empty', () => { + (() => new OsoBridgeClient('', TIMEOUT)).should.throw( + 'OsoBridgeClient: awmAsyncUrl is required', + ); + }); + + it('strips trailing slash from url', () => { + const c = new OsoBridgeClient(`${BASE_URL}/`, TIMEOUT); + nock(BASE_URL).get('/health').reply(200, { status: 'ok' }); + return c.health().should.be.fulfilled(); + }); + }); + + describe('submit()', () => { + it('sets X-OSO-Source for a single source', async () => { + const n = nock(BASE_URL) + .post('/api/tbtc/multisig/sign') + .matchHeader('x-oso-source', 'user') + .reply(202, { jobId: 'job-123' }); + + await client.submit({ + path: '/api/tbtc/multisig/sign', + body: { foo: 'bar' }, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }); + + n.done(); + }); + + it('sets X-OSO-Source as comma-joined for multiple sources', async () => { + const n = nock(BASE_URL) + .post('/api/tbtc/key/independent') + .matchHeader('x-oso-source', 'user,backup') + .reply(202, { jobId: 'job-456' }); + + await client.submit({ + path: '/api/tbtc/key/independent', + body: { foo: 'bar' }, + sources: [KeySource.USER, KeySource.BACKUP], + operationType: 'multisig_keygen', + }); + + n.done(); + }); + + it('sets X-OSO-Operation header', async () => { + const n = nock(BASE_URL) + .post('/api/tbtc/multisig/sign') + .matchHeader('x-oso-operation', 'multisig_sign') + .reply(202, { jobId: 'job-123' }); + + await client.submit({ + path: '/api/tbtc/multisig/sign', + body: { foo: 'bar' }, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }); + + n.done(); + }); + + it('sets X-Idempotency-Key when provided', async () => { + const n = nock(BASE_URL) + .post('/api/tbtc/multisig/sign') + .matchHeader('x-idempotency-key', 'idem-key-abc') + .reply(202, { jobId: 'job-123' }); + + await client.submit({ + path: '/api/tbtc/multisig/sign', + body: { foo: 'bar' }, + sources: [KeySource.USER], + operationType: 'multisig_sign', + idempotencyKey: 'idem-key-abc', + }); + + n.done(); + }); + + it('omits X-Idempotency-Key when not provided', async () => { + const n = nock(BASE_URL) + .post('/api/tbtc/multisig/sign') + .matchHeader('x-idempotency-key', (val) => val === undefined) + .reply(202, { jobId: 'job-123' }); + + await client.submit({ + path: '/api/tbtc/multisig/sign', + body: { foo: 'bar' }, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }); + + n.done(); + }); + + it('forwards body as-is', async () => { + const body = { signablePayload: 'deadbeef', walletId: 'w-123' }; + const n = nock(BASE_URL) + .post('/api/tbtc/multisig/sign', body) + .reply(202, { jobId: 'job-123' }); + + await client.submit({ + path: '/api/tbtc/multisig/sign', + body, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }); + + n.done(); + }); + + it('returns jobId from response', async () => { + nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(202, { jobId: 'job-789' }); + + const result = await client.submit({ + path: '/api/tbtc/multisig/sign', + body: {}, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }); + + result.should.have.property('jobId', 'job-789'); + }); + + it('normalizes path without leading slash', async () => { + const n = nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(202, { jobId: 'job-123' }); + + await client.submit({ + path: 'api/tbtc/multisig/sign', + body: {}, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }); + + n.done(); + }); + + it('throws on invalid response shape', async () => { + nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(202, { notAJobId: true }); + + await client + .submit({ + path: '/api/tbtc/multisig/sign', + body: {}, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }) + .should.be.rejectedWith(/bridge returned unexpected response for submit/); + }); + + it('throws on 400', async () => { + nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(400, { message: 'bad input' }); + + await client + .submit({ + path: '/api/tbtc/multisig/sign', + body: {}, + sources: [KeySource.USER], + operationType: 'multisig_sign', + }) + .should.be.rejectedWith('bad input'); + }); + }); + + describe('getJob()', () => { + it('GETs /job/:jobId and returns BridgeJobResponse', async () => { + const n = nock(BASE_URL).get('/job/job-123').reply(200, mockJob); + + const result = await client.getJob('job-123'); + + result.should.have.property('jobId', 'job-123'); + result.should.have.property('status', 'awaiting_bitgo'); + n.done(); + }); + + it('throws on 404', async () => { + nock(BASE_URL).get('/job/missing').reply(404, { message: 'job not found' }); + + await client.getJob('missing').should.be.rejectedWith('job not found'); + }); + + it('throws on invalid response shape', async () => { + nock(BASE_URL).get('/job/job-123').reply(200, { bad: 'shape' }); + + await client + .getJob('job-123') + .should.be.rejectedWith(/bridge returned unexpected response for getJob/); + }); + }); + + describe('updateJob()', () => { + it('PATCHes /job/:jobId with correct body', async () => { + const n = nock(BASE_URL) + .patch('/job/job-123', { version: 1, status: 'complete', result: { txid: 'abc' } }) + .reply(204); + + await client.updateJob({ + jobId: 'job-123', + version: 1, + status: 'complete', + result: { txid: 'abc' }, + }); + + n.done(); + }); + + it('returns void on 204', async () => { + nock(BASE_URL).patch('/job/job-123').reply(204); + + const result = await client.updateJob({ + jobId: 'job-123', + version: 1, + status: 'failed', + error: 'timeout', + }); + + (result === undefined).should.be.true(); + }); + + it('throws on 409', async () => { + nock(BASE_URL).patch('/job/job-123').reply(409, { message: 'version conflict' }); + + await client + .updateJob({ jobId: 'job-123', version: 1, status: 'complete' }) + .should.be.rejectedWith('version conflict'); + }); + }); + + describe('listJobs()', () => { + it('GETs /jobs without query when status omitted', async () => { + const n = nock(BASE_URL) + .get('/jobs') + .reply(200, { jobs: [mockJob] }); + + const result = await client.listJobs({}); + + result.jobs.should.have.length(1); + result.jobs[0].should.have.property('jobId', 'job-123'); + n.done(); + }); + + it('GETs /jobs?status=awaiting_bitgo when status provided', async () => { + const n = nock(BASE_URL) + .get('/jobs') + .query({ status: 'awaiting_bitgo' }) + .reply(200, { jobs: [mockJob] }); + + const result = await client.listJobs({ status: 'awaiting_bitgo' }); + + result.jobs.should.have.length(1); + n.done(); + }); + + it('returns empty array when jobs list is empty', async () => { + nock(BASE_URL).get('/jobs').reply(200, { jobs: [] }); + + const result = await client.listJobs({}); + + result.jobs.should.have.length(0); + }); + + it('throws on malformed envelope missing jobs array', async () => { + nock(BASE_URL) + .get('/jobs') + .reply(200, { data: [mockJob] }); + + await client + .listJobs({}) + .should.be.rejectedWith(/bridge returned unexpected envelope for listJobs/); + }); + }); + + describe('health()', () => { + it('GETs /health and returns body', async () => { + const n = nock(BASE_URL).get('/health').reply(200, { status: 'ok' }); + + const result = await client.health(); + + result.should.have.property('status', 'ok'); + n.done(); + }); + }); + + describe('error handling', () => { + it('throws on 401', async () => { + nock(BASE_URL).get('/job/job-123').reply(401, { message: 'unauthorized' }); + + await client.getJob('job-123').should.be.rejectedWith('unauthorized'); + }); + + it('throws with status code on unexpected status', async () => { + nock(BASE_URL).get('/job/job-123').reply(503, { message: 'service unavailable' }); + + await client + .getJob('job-123') + .should.be.rejectedWith( + /OsoBridgeClient returned unexpected response \[503\]: service unavailable/, + ); + }); + }); +}); diff --git a/src/advancedWalletManager/keyProviderClient/keyProviderClient.ts b/src/advancedWalletManager/keyProviderClient/keyProviderClient.ts index 8e2bbdcf..d944289a 100644 --- a/src/advancedWalletManager/keyProviderClient/keyProviderClient.ts +++ b/src/advancedWalletManager/keyProviderClient/keyProviderClient.ts @@ -1,8 +1,6 @@ -import * as superagent from 'superagent'; import { AdvancedWalletManagerConfig, isMasterExpressConfig, TlsMode } from '../../shared/types'; import { PostKeyResponseSchema, PostKeyParams, PostKeyResponse } from './types/postKey'; import { GetKeyResponseSchema, GetKeyParams, GetKeyResponse } from './types/getKey'; - import { DecryptDataKeyResponseSchema, DecryptDataKeyParams, @@ -20,18 +18,11 @@ import { } from './types/generateKey'; import { SignResponseSchema, SignParams, SignResponse } from './types/sign'; import https from 'https'; -import { BadRequestError, ConflictError, NotFoundError } from '../../shared/errors'; import { URL } from 'url'; - import logger from '../../shared/logger'; +import { BaseHttpClient } from '../../shared/httpClient'; -type QueryArg = Parameters[0]; -type BodyArg = Parameters[0]; - -export class KeyProviderClient { - private readonly url: string; - private readonly agent?: https.Agent; - +export class KeyProviderClient extends BaseHttpClient { constructor(cfg: AdvancedWalletManagerConfig) { if (isMasterExpressConfig(cfg)) { logger.error('key provider client cannot be initialized in master express mode'); @@ -47,10 +38,12 @@ export class KeyProviderClient { } const urlObj = new URL(cfg.keyProviderUrl); + let agent: https.Agent | undefined; + if (cfg.tlsMode === TlsMode.MTLS) { urlObj.protocol = 'https:'; if (cfg.keyProviderServerCaCert || cfg.keyProviderServerCertAllowSelfSigned) { - this.agent = new https.Agent({ + agent = new https.Agent({ ca: cfg.keyProviderServerCaCert, cert: cfg.keyProviderClientTlsCert, key: cfg.keyProviderClientTlsKey, @@ -61,51 +54,7 @@ export class KeyProviderClient { urlObj.protocol = 'http:'; } - this.url = urlObj.toString().replace(/\/$/, ''); - } - - private errorHandler(error: superagent.ResponseError, errorLog: string): never { - logger.error(errorLog, error); - - if (['ECONNREFUSED', 'ENOTFOUND', 'ECONNRESET', 'ETIMEDOUT'].includes((error as any).code)) { - throw error; - } - - switch (error.status) { - case 400: - throw new BadRequestError(error.response?.body.message); - case 404: - throw new NotFoundError(error.response?.body.message); - case 409: - throw new ConflictError(error.response?.body.message); - case 500: - throw new Error(error.response?.body.message); - default: - throw new Error( - `key provider returned unexpected response.${error.status ? ` ${error.status}` : ''}${ - error.response?.body.message ? `: ${error.response?.body.message}` : '' - }`, - ); - } - } - - private async call( - method: M, - url: string, - options: { errorContext: string } & (M extends 'get' - ? { query?: QueryArg } - : { body?: BodyArg }), - ): Promise { - try { - let req = - method === 'get' - ? superagent.get(url).query((options as unknown as { query?: QueryArg }).query ?? {}) - : superagent.post(url).send((options as unknown as { body?: BodyArg }).body); - if (this.agent) req = req.agent(this.agent); - return await req; - } catch (error: any) { - this.errorHandler(error, options.errorContext); - } + super(urlObj.toString(), cfg.timeout, agent); } async postKey(params: PostKeyParams): Promise { @@ -115,10 +64,7 @@ export class KeyProviderClient { params.source, ); - const response = await this.call('post', `${this.url}/key`, { - body: params, - errorContext: 'Error posting key to key provider', - }); + const response = await this.call('post', `${this.url}/key`, { body: params }); try { PostKeyResponseSchema.parse(response.body); @@ -144,7 +90,6 @@ export class KeyProviderClient { const response = await this.call('get', `${this.url}/key/${params.pub}`, { query: { source: params.source }, - errorContext: 'Error getting key from key provider', }); try { @@ -168,10 +113,7 @@ export class KeyProviderClient { params.source, ); - const response = await this.call('post', `${this.url}/key/generate`, { - body: params, - errorContext: 'Error generating key via key provider', - }); + const response = await this.call('post', `${this.url}/key/generate`, { body: params }); try { GenerateKeyResponseSchema.parse(response.body); @@ -190,10 +132,7 @@ export class KeyProviderClient { async sign(params: SignParams): Promise { logger.info('Signing via key provider with pub: %s and source: %s', params.pub, params.source); - const response = await this.call('post', `${this.url}/sign`, { - body: params, - errorContext: 'Error signing via key provider', - }); + const response = await this.call('post', `${this.url}/sign`, { body: params }); try { SignResponseSchema.parse(response.body); @@ -212,10 +151,7 @@ export class KeyProviderClient { async generateDataKey(params: GenerateDataKeyParams): Promise { logger.info('Generating data key from key provider with type: %s', params.keyType); - const response = await this.call('post', `${this.url}/generateDataKey`, { - body: params, - errorContext: 'Error generating data key from key provider', - }); + const response = await this.call('post', `${this.url}/generateDataKey`, { body: params }); try { GenerateDataKeyResponseSchema.parse(response.body); @@ -235,10 +171,7 @@ export class KeyProviderClient { } async decryptDataKey(params: DecryptDataKeyParams): Promise { - const response = await this.call('post', `${this.url}/decryptDataKey`, { - body: params, - errorContext: 'Error decrypting data key from key provider', - }); + const response = await this.call('post', `${this.url}/decryptDataKey`, { body: params }); try { DecryptDataKeyResponseSchema.parse(response.body); diff --git a/src/masterBitgoExpress/clients/bridgeClient.ts b/src/masterBitgoExpress/clients/bridgeClient.ts new file mode 100644 index 00000000..159520ae --- /dev/null +++ b/src/masterBitgoExpress/clients/bridgeClient.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import { BaseHttpClient } from '../../shared/httpClient'; +import logger from '../../shared/logger'; +import { + BridgeClient, + BridgeJobResponse, + BridgeJobResponseSchema, + HealthResponse, + ListJobsParams, + SubmitParams, + SubmitResponse, + SubmitResponseSchema, + UpdateJobParams, +} from './bridgeClient.types'; + +export class OsoBridgeClient extends BaseHttpClient implements BridgeClient { + constructor(url: string, timeout: number) { + if (!url) { + throw new Error('OsoBridgeClient: awmAsyncUrl is required'); + } + super(url, timeout); + } + + private parseOrThrow(schema: z.ZodType, data: unknown, ctx: string): T { + try { + return schema.parse(data); + } catch (error: any) { + throw new Error( + `bridge returned unexpected response for ${ctx}${ + error.message ? `: ${error.message}` : '' + }`, + ); + } + } + + async submit(params: SubmitParams): Promise { + const path = params.path.startsWith('/') ? params.path : `/${params.path}`; + const headers: Record = { + 'X-OSO-Source': params.sources.join(','), + 'X-OSO-Operation': params.operationType, + }; + if (params.idempotencyKey) { + headers['X-Idempotency-Key'] = params.idempotencyKey; + } + + const response = await this.call('post', `${this.url}${path}`, { + body: params.body, + headers, + }); + + return this.parseOrThrow(SubmitResponseSchema, response.body, 'submit'); + } + + async getJob(jobId: string): Promise { + logger.debug('bridge getJob: %s', jobId); + + const response = await this.call('get', `${this.url}/job/${jobId}`); + + return this.parseOrThrow(BridgeJobResponseSchema, response.body, `getJob ${jobId}`); + } + + async updateJob(params: UpdateJobParams): Promise { + logger.debug('bridge updateJob: %s -> %s', params.jobId, params.status); + + const { jobId, ...body } = params; + await this.call('patch', `${this.url}/job/${jobId}`, { body }); + } + + async listJobs(params: ListJobsParams): Promise<{ jobs: BridgeJobResponse[] }> { + const query: Record = {}; + if (params.status) { + query.status = params.status; + } + + const response = await this.call('get', `${this.url}/jobs`, { query }); + + if (!Array.isArray(response.body?.jobs)) { + throw new Error('bridge returned unexpected envelope for listJobs: missing jobs array'); + } + + const jobs: BridgeJobResponse[] = []; + for (const job of response.body.jobs) { + jobs.push(this.parseOrThrow(BridgeJobResponseSchema, job, 'listJobs job shape')); + } + return { jobs }; + } + + async health(): Promise { + const response = await this.call('get', `${this.url}/health`); + return response.body; + } +} diff --git a/src/masterBitgoExpress/clients/bridgeClient.types.ts b/src/masterBitgoExpress/clients/bridgeClient.types.ts new file mode 100644 index 00000000..402af437 --- /dev/null +++ b/src/masterBitgoExpress/clients/bridgeClient.types.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import { UserOrBackupKey } from '../../shared/types'; +import { BodyArg } from '../../shared/httpClient'; + +export const OperationTypeSchema = z.enum([ + 'multisig_sign', + 'multisig_keygen', + 'multisig_recovery', + 'mpc_sign', + 'mpc_keygen', +]); +export type OperationType = z.infer; + +export const JobStatusSchema = z.enum([ + 'awaiting_oso', + 'awaiting_bitgo', + 'complete', + 'failed', + 'expired', +]); +export type JobStatus = z.infer; + +const unknownOptional = z.unknown().optional(); + +export const SubmitResponseSchema = z.object({ + jobId: z.string(), +}); +export type SubmitResponse = z.infer; + +export const BridgeJobResponseSchema = z.object({ + jobId: z.string(), + status: JobStatusSchema, + version: z.number(), + coin: z.string(), + operationType: OperationTypeSchema, + awmResponse: unknownOptional, + awmBackupResponse: unknownOptional, + result: unknownOptional, + error: z.string().optional(), + currentRound: z.number().optional(), + totalRounds: z.number().optional(), + sessionState: unknownOptional, + createdAt: z.string(), + updatedAt: z.string(), +}); +export type BridgeJobResponse = z.infer; + +export interface SubmitParams { + path: string; + body: BodyArg; + sources: UserOrBackupKey[]; + operationType: OperationType; + idempotencyKey?: string; +} + +export interface UpdateJobParams { + jobId: string; + version: number; + status: Extract; + result?: unknown; + error?: string; +} + +export interface ListJobsParams { + status?: JobStatus; +} + +export interface HealthResponse { + status: string; +} + +export interface BridgeClient { + submit(params: SubmitParams): Promise; + getJob(jobId: string): Promise; + updateJob(params: UpdateJobParams): Promise; + listJobs(params: ListJobsParams): Promise<{ jobs: BridgeJobResponse[] }>; + health(): Promise; +} diff --git a/src/shared/httpClient.ts b/src/shared/httpClient.ts new file mode 100644 index 00000000..a36b7915 --- /dev/null +++ b/src/shared/httpClient.ts @@ -0,0 +1,88 @@ +import * as superagent from 'superagent'; +import https from 'https'; +import { BadRequestError, ConflictError, NotFoundError, UnauthorizedError } from './errors'; +import logger from './logger'; + +export type HttpMethod = 'get' | 'post' | 'patch'; + +export type QueryArg = Parameters[0]; +export type BodyArg = Parameters[0]; +type HeadersArg = Record; + +type CallOptions = M extends 'get' + ? { query?: QueryArg; headers?: HeadersArg } + : M extends 'patch' + ? { body?: BodyArg; query?: QueryArg; headers?: HeadersArg } + : { body?: BodyArg; headers?: HeadersArg }; + +export abstract class BaseHttpClient { + protected readonly url: string; + private readonly timeout: number; + private readonly agent?: https.Agent; + + constructor(url: string, timeout: number, agent?: https.Agent) { + // Strip trailing slash from the base URL if one exists + this.url = url.replace(/\/$/, ''); + this.timeout = timeout; + this.agent = agent; + } + + protected errorHandler(error: superagent.ResponseError, ctx: string): never { + logger.error(ctx, error); + + if (['ECONNREFUSED', 'ENOTFOUND', 'ECONNRESET', 'ETIMEDOUT'].includes((error as any).code)) { + throw error; + } + + switch (error.status) { + case 400: + throw new BadRequestError(error.response?.body.message); + case 401: + throw new UnauthorizedError(error.response?.body.message); + case 404: + throw new NotFoundError(error.response?.body.message); + case 409: + throw new ConflictError(error.response?.body.message); + default: + throw new Error( + `${this.constructor.name} returned unexpected response${ + error.status ? ` [${error.status}]` : '' + }${error.response?.body.message ? `: ${error.response.body.message}` : ''}`, + ); + } + } + + protected async call( + method: M, + url: string, + options?: CallOptions, + ): Promise { + try { + let req = + method === 'get' + ? superagent.get(url).query((options as CallOptions<'get'>)?.query ?? {}) + : method === 'post' + ? superagent.post(url).send((options as CallOptions<'post'>)?.body) + : superagent + .patch(url) + .send((options as CallOptions<'patch'>)?.body) + .query((options as CallOptions<'patch'>)?.query ?? {}); + + req = req.timeout(this.timeout); + + if (this.agent) { + req = req.agent(this.agent); + } + + if (options?.headers) { + for (const [key, value] of Object.entries(options.headers)) { + req = req.set(key, value); + } + } + + return await req; + } catch (error: any) { + return this.errorHandler(error, `${method.toUpperCase()} ${url}`); + } + } +}