Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions packages/sdk/src/resources/base.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

export interface ApiFetchResult {
status: number;
data: unknown;
rawBody: string;
}

export function isRecord(value: unknown): value is Record<string, unknown> {
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<ApiFetchResult> {
if (this.verbose) {
const redactedHeaders = { ...opts.headers };
if (redactedHeaders.Authorization)
redactedHeaders.Authorization = 'Bearer <redacted>';
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<ApiFetchResult> {
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,
});
}
}
123 changes: 10 additions & 113 deletions packages/sdk/src/resources/transactions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,16 +11,6 @@ import type {
TransactionsPage,
} from '@/types/index';

interface ApiFetchOptions {
method: string;
url: string;
headers?: Record<string, string>;
}

function isRecord(value: unknown): value is Record<string, unknown> {
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`);
Expand All @@ -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,
Expand Down Expand Up @@ -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 <redacted>';
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 {
Expand Down Expand Up @@ -254,23 +159,15 @@ export class TransactionsResource implements ITransactionsResource {
});

if (status < 200 || status >= 300) {
const body = data as Record<string, unknown> | 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 {
return normalizeTransactionsPage(data);
} 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 },
);
}
Expand Down
Loading