From 28e1c3c6fa364240e4d4f9861d7bd2905acbf61b Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Mon, 13 Jul 2026 23:56:09 -0400 Subject: [PATCH] refactor: extract shared HTTP transport into BaseResource Committed-By-Agent: claude --- packages/sdk/src/resources/base.ts | 147 +++++++++++++++++++++ packages/sdk/src/resources/transactions.ts | 123 ++--------------- 2 files changed, 157 insertions(+), 113 deletions(-) create mode 100644 packages/sdk/src/resources/base.ts diff --git a/packages/sdk/src/resources/base.ts b/packages/sdk/src/resources/base.ts new file mode 100644 index 0000000..4e4da8b --- /dev/null +++ b/packages/sdk/src/resources/base.ts @@ -0,0 +1,147 @@ +import { + type LinkOptions, + requireFetchImplementation, + resolveLinkSdkConfig, +} from '@/config'; +import { LinkApiError, LinkTransportError } from '@/errors'; +import type { AccessTokenProvider } from '@/resources/interfaces'; + +export interface ApiFetchOptions { + method: string; + url: string; + headers?: Record; +} + +export interface ApiFetchResult { + status: number; + data: unknown; + rawBody: string; +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new TypeError(`Expected ${field} to be a boolean`); + } + return value; +} + +export function extractErrorMessage(data: unknown, rawBody: string): string { + if (isRecord(data)) { + if (typeof data.error === 'string') { + return data.error; + } + if (isRecord(data.error)) { + const nested = data.error; + if (typeof nested.message === 'string') { + return nested.message; + } + if (typeof nested.code === 'string') { + return nested.code; + } + } + if (typeof data.message === 'string') { + return data.message; + } + } + + return rawBody || 'unknown error'; +} + +export abstract class BaseResource { + protected readonly verbose: boolean; + protected readonly getAccessToken: AccessTokenProvider; + protected readonly fetchImpl: typeof globalThis.fetch; + protected readonly endpoint: string; + protected readonly logger: { debug(message: string): void }; + + constructor(options: LinkOptions, endpointPath: string) { + const config = resolveLinkSdkConfig(options); + this.verbose = config.verbose; + this.getAccessToken = config.getAccessToken; + this.fetchImpl = requireFetchImplementation(config); + this.endpoint = `${config.apiBaseUrl}${endpointPath}`; + this.logger = config.logger; + } + + protected async rawFetch(opts: ApiFetchOptions): Promise { + if (this.verbose) { + const redactedHeaders = { ...opts.headers }; + if (redactedHeaders.Authorization) + redactedHeaders.Authorization = 'Bearer '; + this.logger.debug(`> ${opts.method} ${opts.url}`); + this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); + } + + let response: Response; + try { + response = await this.fetchImpl(opts.url, { + method: opts.method, + headers: opts.headers, + }); + } catch (error) { + throw new LinkTransportError( + `Request failed: ${opts.method} ${opts.url}`, + { cause: error }, + ); + } + const rawBody = await response.text(); + + let data: unknown = null; + try { + data = JSON.parse(rawBody); + } catch { + // non-JSON response (e.g., from load balancer) + } + + if (this.verbose) { + this.logger.debug(`< ${response.status} ${response.statusText}`); + response.headers.forEach((value, key) => { + this.logger.debug(` ${key}: ${value}`); + }); + this.logger.debug(rawBody); + } + + return { status: response.status, data, rawBody }; + } + + protected async apiFetch(opts: ApiFetchOptions): Promise { + const token = await this.getAccessToken(); + const authedOpts = { + ...opts, + headers: { + ...opts.headers, + Authorization: `Bearer ${token}`, + }, + }; + + const res = await this.rawFetch(authedOpts); + + if (res.status === 401) { + const refreshedToken = await this.getAccessToken({ forceRefresh: true }); + authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; + return this.rawFetch(authedOpts); + } + + return res; + } + + protected throwApiError( + operation: string, + status: number, + data: unknown, + rawBody: string, + cause?: unknown, + ): never { + const msg = extractErrorMessage(data, rawBody); + throw new LinkApiError(`Failed to ${operation} (${status}): ${msg}`, { + status, + rawBody, + details: data, + cause, + }); + } +} diff --git a/packages/sdk/src/resources/transactions.ts b/packages/sdk/src/resources/transactions.ts index 70c2e86..cff88bb 100644 --- a/packages/sdk/src/resources/transactions.ts +++ b/packages/sdk/src/resources/transactions.ts @@ -1,11 +1,7 @@ -import { - type LinkOptions, - requireFetchImplementation, - resolveLinkSdkConfig, -} from '@/config'; -import { LinkApiError, LinkTransportError } from '@/errors'; +import type { LinkOptions } from '@/config'; +import { LinkApiError } from '@/errors'; +import { BaseResource, isRecord, requireBoolean } from '@/resources/base'; import type { - AccessTokenProvider, ITransactionsResource, ListTransactionsParams, } from '@/resources/interfaces'; @@ -15,16 +11,6 @@ import type { TransactionsPage, } from '@/types/index'; -interface ApiFetchOptions { - method: string; - url: string; - headers?: Record; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function requireString(value: unknown, field: string): string { if (typeof value !== 'string') { throw new TypeError(`Expected ${field} to be a string`); @@ -49,13 +35,6 @@ function requireNumber(value: unknown, field: string): number { return value; } -function requireBoolean(value: unknown, field: string): boolean { - if (typeof value !== 'boolean') { - throw new TypeError(`Expected ${field} to be a boolean`); - } - return value; -} - function requireTransactionOrigin( value: unknown, field: string, @@ -126,86 +105,12 @@ function normalizeTransactionsPage(value: unknown): TransactionsPage { }; } -export class TransactionsResource implements ITransactionsResource { - private readonly verbose: boolean; - private readonly getAccessToken: AccessTokenProvider; - private readonly fetchImpl: typeof globalThis.fetch; - private readonly endpoint: string; - private readonly logger: { debug(message: string): void }; - +export class TransactionsResource + extends BaseResource + implements ITransactionsResource +{ constructor(options: LinkOptions = {}) { - const config = resolveLinkSdkConfig(options); - this.verbose = config.verbose; - this.getAccessToken = config.getAccessToken; - this.fetchImpl = requireFetchImplementation(config); - this.endpoint = `${config.apiBaseUrl}/transactions`; - this.logger = config.logger; - } - - private async rawFetch( - opts: ApiFetchOptions, - ): Promise<{ status: number; data: unknown; rawBody: string }> { - if (this.verbose) { - const redactedHeaders = { ...opts.headers }; - if (redactedHeaders.Authorization) - redactedHeaders.Authorization = 'Bearer '; - this.logger.debug(`> ${opts.method} ${opts.url}`); - this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); - } - - let response: Response; - try { - response = await this.fetchImpl(opts.url, { - method: opts.method, - headers: opts.headers, - }); - } catch (error) { - throw new LinkTransportError( - `Request failed: ${opts.method} ${opts.url}`, - { cause: error }, - ); - } - const rawBody = await response.text(); - - let data: unknown = null; - try { - data = JSON.parse(rawBody); - } catch { - // non-JSON response (e.g., from load balancer) - } - - if (this.verbose) { - this.logger.debug(`< ${response.status} ${response.statusText}`); - response.headers.forEach((value, key) => { - this.logger.debug(` ${key}: ${value}`); - }); - this.logger.debug(rawBody); - } - - return { status: response.status, data, rawBody }; - } - - private async apiFetch( - opts: ApiFetchOptions, - ): Promise<{ status: number; data: unknown; rawBody: string }> { - const token = await this.getAccessToken(); - const authedOpts = { - ...opts, - headers: { - ...opts.headers, - Authorization: `Bearer ${token}`, - }, - }; - - const res = await this.rawFetch(authedOpts); - - if (res.status === 401) { - const refreshedToken = await this.getAccessToken({ forceRefresh: true }); - authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; - return this.rawFetch(authedOpts); - } - - return res; + super(options, '/transactions'); } private buildUrl(params: ListTransactionsParams): string { @@ -254,15 +159,7 @@ export class TransactionsResource implements ITransactionsResource { }); if (status < 200 || status >= 300) { - const body = data as Record | null; - const msg = - (body?.error as string | undefined) ?? - (body?.message as string | undefined) ?? - (rawBody || 'unknown error'); - throw new LinkApiError( - `Failed to list transactions (${status}): ${msg}`, - { status, rawBody, details: data }, - ); + this.throwApiError('list transactions', status, data, rawBody); } try { @@ -270,7 +167,7 @@ export class TransactionsResource implements ITransactionsResource { } catch (error) { const reason = error instanceof Error ? `: ${error.message}` : ''; throw new LinkApiError( - `Failed to list transactions (200): invalid response shape${reason}`, + `Failed to list transactions (${status}): invalid response shape${reason}`, { status, rawBody, details: data, cause: error }, ); }