@sudowealth/schwab-api
Version:
TypeScript client for Charles Schwab API with OAuth support, market data, trading functionality, and complete type safety
321 lines (320 loc) • 15.1 kB
JavaScript
import { SchwabApiError, createSchwabApiError, handleApiError, extractErrorMetadata, } from '../errors';
import { getSchwabApiConfigDefaults, resolveBaseUrl, } from './config';
// Global fetch handler that will be used as a fallback
const globalFetch = fetch;
/**
* Create a new request context with the given config and fetch function
*/
export function createRequestContext(config = getSchwabApiConfigDefaults(), fetchFn = globalFetch) {
// Ensure we have a logger (use the one from config or fall back to default)
const logger = config.logger || getSchwabApiConfigDefaults().logger;
return {
config,
fetchFn,
logger,
};
}
// Utility function for logging
function log(context, level, message, data) {
const { config, logger } = context;
if (!config.enableLogging)
return;
const logLevels = {
debug: 0,
info: 1,
warn: 2,
error: 3,
none: 4,
};
// Only log if the current level is sufficient
if (logLevels[level] >= logLevels[config.logLevel]) {
if (data !== undefined) {
logger[level](message, data);
}
else {
logger[level](message);
}
}
}
/**
* Primary implementation for making API requests
* All API requests should use this function
*/
async function schwabFetchWithContext(context, endpoint, options) {
const { config, fetchFn } = context;
log(context, 'info', `Requesting: ${endpoint.method} ${endpoint.path}`);
log(context, 'debug', 'Request details:', { endpoint, options });
const { pathParams, queryParams, body, init = {} } = options ?? {};
const { path: endpointTemplate, method, pathSchema, querySchema, bodySchema, responseSchema, errorSchema, } = endpoint;
// Validate Path Parameters
let validatedPathParams = pathParams;
if (pathSchema) {
const parsed = pathSchema.safeParse(pathParams ?? {});
if (!parsed.success) {
// Format the validation errors into a more readable message
const errors = parsed.error.format();
let validationDetails = '';
// Try to extract specific field validation errors
try {
const errorEntries = Object.entries(errors)
.filter(([key]) => key !== '_errors')
.map(([key, value]) => {
if (typeof value === 'object' &&
'_errors' in value &&
Array.isArray(value._errors)) {
return `'${key}': ${value._errors.join(', ')}`;
}
return null;
})
.filter(Boolean);
if (errorEntries.length > 0) {
validationDetails = ` - Validation errors: ${errorEntries.join('; ')}`;
}
}
catch (e) {
// If error formatting fails, just use the default message
log(context, 'warn', 'Failed to format validation errors', e);
}
throw createSchwabApiError(400, parsed.error.format(), `Invalid path parameters for ${method} ${endpointTemplate}${validationDetails}`);
}
validatedPathParams = parsed.data;
}
else if (pathParams && Object.keys(pathParams).length > 0) {
log(context, 'warn', `Path parameters provided for ${method} ${endpointTemplate}, but no pathSchema is defined.`);
}
// Validate Query Parameters
let validatedQueryParams = queryParams;
if (querySchema) {
const parsed = querySchema.safeParse(queryParams ?? {});
if (!parsed.success) {
// Format the validation errors into a more readable message
const errors = parsed.error.format();
let validationDetails = '';
// Try to extract specific field validation errors
try {
const errorEntries = Object.entries(errors)
.filter(([key]) => key !== '_errors')
.map(([key, value]) => {
if (typeof value === 'object' &&
'_errors' in value &&
Array.isArray(value._errors)) {
return `'${key}': ${value._errors.join(', ')}`;
}
return null;
})
.filter(Boolean);
if (errorEntries.length > 0) {
validationDetails = ` - Validation errors: ${errorEntries.join('; ')}`;
}
}
catch (e) {
// If error formatting fails, just use the default message
log(context, 'warn', 'Failed to format validation errors', e);
}
throw createSchwabApiError(400, parsed.error.format(), `Invalid query parameters for ${method} ${endpointTemplate}${validationDetails}`);
}
validatedQueryParams = parsed.data;
}
else if (queryParams && Object.keys(queryParams).length > 0) {
log(context, 'warn', `Query parameters provided for ${method} ${endpointTemplate}, but no querySchema is defined.`);
}
const url = buildUrlWithContext(context, endpointTemplate, validatedPathParams, validatedQueryParams);
// Validate Body
let validatedBody = body;
if (bodySchema) {
const parsed = bodySchema.safeParse(body ?? {}); // Allow undefined body if schema supports it
if (!parsed.success) {
// Format the validation errors into a more readable message
const errors = parsed.error.format();
let validationDetails = '';
// Try to extract specific field validation errors
try {
const errorEntries = Object.entries(errors)
.filter(([key]) => key !== '_errors')
.map(([key, value]) => {
if (typeof value === 'object' &&
'_errors' in value &&
Array.isArray(value._errors)) {
return `'${key}': ${value._errors.join(', ')}`;
}
return null;
})
.filter(Boolean);
if (errorEntries.length > 0) {
validationDetails = ` - Validation errors: ${errorEntries.join('; ')}`;
}
}
catch (e) {
// If error formatting fails, just use the default message
log(context, 'warn', 'Failed to format validation errors', e);
}
throw createSchwabApiError(400, parsed.error.format(), `Invalid request body for ${method} ${endpointTemplate}${validationDetails}`);
}
validatedBody = parsed.data;
}
else if (body) {
log(context, 'warn', `Request body provided for ${method} ${endpointTemplate}, but no bodySchema is defined.`);
}
const headers = {
...(options?.headers ?? {}), // User-provided headers
Accept: 'application/json',
};
const requestInit = {
...init,
method,
headers,
};
if (validatedBody !== undefined &&
(method === 'POST' || method === 'PUT' || method === 'PATCH')) {
requestInit.body = JSON.stringify(validatedBody);
headers['Content-Type'] = 'application/json';
}
log(context, 'debug', `Fetching URL: ${url.toString()}`, { requestInit });
try {
// Add timeout support
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
const response = await fetchFn(new Request(url.toString(), {
...requestInit,
signal: controller.signal,
}));
// Clear the timeout
clearTimeout(timeoutId);
log(context, 'info', `Response status: ${response.status} for ${method} ${endpointTemplate}`);
if (!response.ok) {
let errorBody;
// Clone the response before attempting to read its body
const responseClone = response.clone();
try {
errorBody = await response.json();
// If we have an endpoint-specific error schema, validate against that first
if (errorSchema) {
const parsedError = errorSchema.safeParse(errorBody);
if (parsedError.success) {
errorBody = parsedError.data;
}
}
}
catch (e) {
log(context, 'warn', `Could not parse error response body as JSON for ${method} ${endpointTemplate}`, e);
// Use the cloned response for the fallback attempt
errorBody = await responseClone.text();
}
// Prepare a more detailed error message
let detailedErrorMessage = `API Error for ${method} ${endpointTemplate}: ${response.statusText}`;
// Add query parameters to error message for debugging
if (validatedQueryParams &&
Object.keys(validatedQueryParams).length > 0) {
detailedErrorMessage += ` - Query params: ${JSON.stringify(validatedQueryParams)}`;
}
// Extract metadata from response headers for better error handling
const metadata = extractErrorMetadata(response);
// Create an error with the appropriate type and structured details
// parseErrorResponse is called inside createSchwabApiError to populate the parsedError field
throw createSchwabApiError(response.status, errorBody, detailedErrorMessage, metadata);
}
// Handle cases like 204 No Content
if (response.status === 204 ||
response.headers.get('content-length') === '0') {
log(context, 'info', `Empty response body for ${method} ${endpointTemplate}.`);
return undefined;
}
const responseData = await response.json();
log(context, 'debug', `Response data for ${method} ${endpointTemplate}:`, responseData);
const parsedResponse = responseSchema.safeParse(responseData);
if (!parsedResponse.success) {
const SENSITIVE_MAX_ERROR_LENGTH = 500; // To avoid overly long messages
const validationErrors = JSON.stringify(parsedResponse.error.format(), null, 2);
const truncatedErrors = validationErrors.length > SENSITIVE_MAX_ERROR_LENGTH
? validationErrors.substring(0, SENSITIVE_MAX_ERROR_LENGTH) +
'... (truncated)'
: validationErrors;
throw createSchwabApiError(500, parsedResponse.error.format(), `Invalid response data structure for ${method} ${endpointTemplate}. Validation errors: ${truncatedErrors}`);
}
return parsedResponse.data;
}
catch (error) {
// Handle AbortError separately
if (error instanceof DOMException && error.name === 'AbortError') {
throw createSchwabApiError(408, // Request Timeout
{ message: 'Request timed out' }, `Request timeout after ${config.timeout}ms for ${method} ${endpointTemplate}`);
}
log(context, 'error', `Fetch failed for ${method} ${endpointTemplate}:`, error);
if (error instanceof SchwabApiError) {
throw error;
}
// Wrap unknown errors
handleApiError(error, `Unexpected error during fetch for ${method} ${endpointTemplate}`);
}
}
/**
* Create an endpoint function using the provided context
* This is the primary way to create API endpoint functions
*/
export function createEndpoint(context, meta) {
return (options = {}) => {
return schwabFetchWithContext(context, meta, options);
};
}
/**
* Build a URL for an API endpoint with the provided context
*/
function buildUrlWithContext(context, endpointTemplate, pathParams, queryParams) {
const { config } = context;
// 1. Substitute Path Parameters
let finalEndpointPath = endpointTemplate;
if (pathParams) {
Object.entries(pathParams).forEach(([key, value]) => {
const curlyPlaceholder = `{${key}}`;
const colonPlaceholderPattern = new RegExp(`:${key}(?=\\/|\\.|$)`, 'g'); // Matches :key followed by /, ., or EOL
let replaced = false;
if (finalEndpointPath.includes(curlyPlaceholder)) {
// Properly escape the curly braces for regex replacement
const escapedPlaceholder = curlyPlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalEndpointPath = finalEndpointPath.replace(new RegExp(escapedPlaceholder, 'g'), encodeURIComponent(String(value)));
replaced = true;
}
// Check and replace colon-style placeholders separately
if (colonPlaceholderPattern.test(finalEndpointPath)) {
finalEndpointPath = finalEndpointPath.replace(colonPlaceholderPattern, encodeURIComponent(String(value)));
replaced = true;
}
if (!replaced) {
log(context, 'warn', `Path parameter '${key}' provided but not found in template '${endpointTemplate}'`);
}
});
}
// Check for unsubstituted placeholders after substitution
const unsubstitutedCurlyPlaceholderRegex = /\{[^\}]+\}/g;
const unsubstitutedColonPlaceholderRegex = /:[a-zA-Z0-9_]+(?=\/|\.|_|$)/g;
const curlyMatches = finalEndpointPath.match(unsubstitutedCurlyPlaceholderRegex);
const colonMatches = finalEndpointPath.match(unsubstitutedColonPlaceholderRegex);
if (curlyMatches || colonMatches) {
const remainingCurly = curlyMatches ? curlyMatches.join(', ') : 'none';
const remainingColon = colonMatches ? colonMatches.join(', ') : 'none';
throw createSchwabApiError(400, undefined, `Unsubstituted placeholders remain in path: ${finalEndpointPath}. Check if all required path parameters were provided. Remaining (curly): ${remainingCurly}. Remaining (colon): ${remainingColon}.`);
}
// 2. Use the centralized base URL resolution
// This ensures consistent URL resolution across the codebase
const baseUrl = resolveBaseUrl(config);
// Add API version to path if not already present
if (!finalEndpointPath.startsWith(`/${config.apiVersion}/`) &&
!finalEndpointPath.includes(`/${config.apiVersion}/`)) {
finalEndpointPath = `/${config.apiVersion}${finalEndpointPath}`;
}
const url = new URL(baseUrl + finalEndpointPath);
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined) {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, String(v)));
}
else {
url.searchParams.set(key, String(value));
}
}
});
}
log(context, 'debug', `Constructed URL: ${url.toString()}`);
return url;
}