@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
414 lines (374 loc) • 13.3 kB
text/typescript
import { toJson } from "../json.js";
import { createLogger, type LogConfig, type Logger } from "../logging/logger.js";
import type { APIResponse } from "./APIResponse.js";
import { createRequestUrl } from "./createRequestUrl.js";
import type { EndpointMetadata } from "./EndpointMetadata.js";
import { EndpointSupplier } from "./EndpointSupplier.js";
import { getErrorResponseBody } from "./getErrorResponseBody.js";
import { getFetchFn } from "./getFetchFn.js";
import { getRequestBody } from "./getRequestBody.js";
import { getResponseBody } from "./getResponseBody.js";
import { Headers } from "./Headers.js";
import { makeRequest } from "./makeRequest.js";
import { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js";
import { requestWithRetries } from "./requestWithRetries.js";
export type FetchFunction = <R = unknown>(args: Fetcher.Args) => Promise<APIResponse<R, Fetcher.Error>>;
export declare namespace Fetcher {
export interface Args {
url: string;
method: string;
contentType?: string;
headers?: Record<string, unknown>;
/**
* @deprecated Prefer `queryString` (produced by `core.url.queryBuilder()`).
* Retained for backwards compatibility with custom fetchers and callers that
* still construct request args with a query-parameter object.
*/
queryParameters?: Record<string, unknown>;
queryString?: string;
body?: unknown;
timeoutMs?: number;
maxRetries?: number;
withCredentials?: boolean;
abortSignal?: AbortSignal;
requestType?: "json" | "file" | "bytes" | "form" | "other";
responseType?: "json" | "blob" | "sse" | "streaming" | "text" | "arrayBuffer" | "binary-response";
duplex?: "half";
endpointMetadata?: EndpointMetadata;
fetchFn?: typeof fetch;
logging?: LogConfig | Logger;
}
export type Error = FailedStatusCodeError | NonJsonError | BodyIsNullError | TimeoutError | UnknownError;
export interface FailedStatusCodeError {
reason: "status-code";
statusCode: number;
body: unknown;
}
export interface NonJsonError {
reason: "non-json";
statusCode: number;
rawBody: string;
}
export interface BodyIsNullError {
reason: "body-is-null";
statusCode: number;
}
export interface TimeoutError {
reason: "timeout";
cause?: unknown;
}
export interface UnknownError {
reason: "unknown";
errorMessage: string;
cause?: unknown;
}
}
const SENSITIVE_HEADERS = new Set([
"authorization",
"www-authenticate",
"x-api-key",
"api-key",
"apikey",
"x-api-token",
"x-auth-token",
"auth-token",
"cookie",
"set-cookie",
"proxy-authorization",
"proxy-authenticate",
"x-csrf-token",
"x-xsrf-token",
"x-session-token",
"x-access-token",
]);
function redactHeaders(headers: Headers | Record<string, string>): Record<string, string> {
const filtered: Record<string, string> = {};
for (const [key, value] of headers instanceof Headers ? headers.entries() : Object.entries(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
filtered[key] = "[REDACTED]";
} else {
filtered[key] = value;
}
}
return filtered;
}
const SENSITIVE_QUERY_PARAMS = new Set([
"api_key",
"api-key",
"apikey",
"token",
"access_token",
"access-token",
"auth_token",
"auth-token",
"password",
"passwd",
"secret",
"api_secret",
"api-secret",
"apisecret",
"key",
"session",
"session_id",
"session-id",
]);
function redactQueryParameters(
queryParameters: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (queryParameters == null) {
return undefined;
}
const redacted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(queryParameters)) {
redacted[key] = SENSITIVE_QUERY_PARAMS.has(key.toLowerCase()) ? "[REDACTED]" : value;
}
return redacted;
}
function redactUrl(url: string): string {
const protocolIndex = url.indexOf("://");
if (protocolIndex === -1) return url;
const afterProtocol = protocolIndex + 3;
// Find the first delimiter that marks the end of the authority section
const pathStart = url.indexOf("/", afterProtocol);
let queryStart = url.indexOf("?", afterProtocol);
let fragmentStart = url.indexOf("#", afterProtocol);
const firstDelimiter = Math.min(
pathStart === -1 ? url.length : pathStart,
queryStart === -1 ? url.length : queryStart,
fragmentStart === -1 ? url.length : fragmentStart,
);
// Find the LAST @ before the delimiter (handles multiple @ in credentials)
let atIndex = -1;
for (let i = afterProtocol; i < firstDelimiter; i++) {
if (url[i] === "@") {
atIndex = i;
}
}
if (atIndex !== -1) {
url = `${url.slice(0, afterProtocol)}[REDACTED]@${url.slice(atIndex + 1)}`;
}
// Recalculate queryStart since url might have changed
queryStart = url.indexOf("?");
if (queryStart === -1) return url;
fragmentStart = url.indexOf("#", queryStart);
const queryEnd = fragmentStart !== -1 ? fragmentStart : url.length;
const queryString = url.slice(queryStart + 1, queryEnd);
if (queryString.length === 0) return url;
// FAST PATH: Quick check if any sensitive keywords present
// Using indexOf is faster than regex for simple substring matching
const lower = queryString.toLowerCase();
const hasSensitive =
lower.includes("token") ||
lower.includes("key") ||
lower.includes("password") ||
lower.includes("passwd") ||
lower.includes("secret") ||
lower.includes("session") ||
lower.includes("auth");
if (!hasSensitive) {
return url;
}
// SLOW PATH: Parse and redact
const redactedParams: string[] = [];
const params = queryString.split("&");
for (const param of params) {
const equalIndex = param.indexOf("=");
if (equalIndex === -1) {
redactedParams.push(param);
continue;
}
const key = param.slice(0, equalIndex);
let shouldRedact = SENSITIVE_QUERY_PARAMS.has(key.toLowerCase());
if (!shouldRedact && key.includes("%")) {
try {
const decodedKey = decodeURIComponent(key);
shouldRedact = SENSITIVE_QUERY_PARAMS.has(decodedKey.toLowerCase());
} catch {}
}
redactedParams.push(shouldRedact ? `${key}=[REDACTED]` : param);
}
return url.slice(0, queryStart + 1) + redactedParams.join("&") + url.slice(queryEnd);
}
async function getHeaders(args: Fetcher.Args): Promise<Headers> {
const newHeaders: Headers = new Headers();
newHeaders.set(
"Accept",
args.responseType === "json"
? "application/json"
: args.responseType === "text"
? "text/plain"
: args.responseType === "sse"
? "text/event-stream"
: "*/*",
);
if (args.body !== undefined && args.contentType != null) {
newHeaders.set("Content-Type", args.contentType);
}
if (args.headers == null) {
return newHeaders;
}
for (const [key, value] of Object.entries(args.headers)) {
const result = await EndpointSupplier.get(value, { endpointMetadata: args.endpointMetadata ?? {} });
if (typeof result === "string") {
newHeaders.set(key, result);
continue;
}
if (result == null) {
continue;
}
newHeaders.set(key, `${result}`);
}
return newHeaders;
}
export async function fetcherImpl<R = unknown>(args: Fetcher.Args): Promise<APIResponse<R, Fetcher.Error>> {
let url = args.url;
if (args.queryString != null && args.queryString.length > 0) {
url = `${url}?${args.queryString}`;
} else {
url = createRequestUrl(args.url, args.queryParameters);
}
const requestBody: BodyInit | undefined = await getRequestBody({
body: args.body,
type: args.requestType ?? "other",
});
const fetchFn = args.fetchFn ?? (await getFetchFn());
const headers = await getHeaders(args);
const logger = createLogger(args.logging);
if (logger.isDebug()) {
const metadata = {
method: args.method,
url: redactUrl(url),
headers: redactHeaders(headers),
queryParameters: redactQueryParameters(args.queryParameters),
hasBody: requestBody != null,
};
logger.debug("Making HTTP request", metadata);
}
try {
const response = await requestWithRetries(
async () =>
makeRequest(
fetchFn,
url,
args.method,
headers,
requestBody,
args.timeoutMs,
args.abortSignal,
args.withCredentials,
args.duplex,
args.responseType === "streaming" || args.responseType === "sse",
),
args.maxRetries,
);
if (response.status >= 200 && response.status < 400) {
if (logger.isDebug()) {
const metadata = {
method: args.method,
url: redactUrl(url),
statusCode: response.status,
responseHeaders: redactHeaders(response.headers),
};
logger.debug("HTTP request succeeded", metadata);
}
const body = await getResponseBody(response, args.responseType);
return {
ok: true,
body: body as R,
headers: response.headers,
rawResponse: toRawResponse(response),
};
} else {
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
statusCode: response.status,
responseHeaders: redactHeaders(Object.fromEntries(response.headers.entries())),
};
logger.error("HTTP request failed with error status", metadata);
}
return {
ok: false,
error: {
reason: "status-code",
statusCode: response.status,
body: await getErrorResponseBody(response),
},
rawResponse: toRawResponse(response),
};
}
} catch (error) {
if (args.abortSignal?.aborted) {
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
};
logger.error("HTTP request was aborted", metadata);
}
return {
ok: false,
error: {
reason: "unknown",
errorMessage: "The user aborted a request",
cause: error,
},
rawResponse: abortRawResponse,
};
} else if (error instanceof Error && error.name === "AbortError") {
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
timeoutMs: args.timeoutMs,
};
logger.error("HTTP request timed out", metadata);
}
return {
ok: false,
error: {
reason: "timeout",
cause: error,
},
rawResponse: abortRawResponse,
};
} else if (error instanceof Error) {
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
errorMessage: error.message,
};
logger.error("HTTP request failed with error", metadata);
}
return {
ok: false,
error: {
reason: "unknown",
errorMessage: error.message,
cause: error,
},
rawResponse: unknownRawResponse,
};
}
if (logger.isError()) {
const metadata = {
method: args.method,
url: redactUrl(url),
error: toJson(error),
};
logger.error("HTTP request failed with unknown error", metadata);
}
return {
ok: false,
error: {
reason: "unknown",
errorMessage: toJson(error),
cause: error,
},
rawResponse: unknownRawResponse,
};
}
}
export const fetcher: FetchFunction = fetcherImpl;