splitwise
Version:
A TypeScript SDK for the Splitwise API.
450 lines • 17.8 kB
JavaScript
;
/**
* HTTP client for the Splitwise API.
*
* Wraps `fetch` with:
* - bearer-token auth (token is fetched per request via `getAccessToken`)
* - automatic snake_case <-> camelCase conversion at the boundary
* - form-urlencoded request bodies (the Splitwise API's default) with an
* opt-in JSON path for new endpoints
* - typed error responses via `createApiError`
* - transparent retries with exponential backoff
* - request timeouts via AbortController
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.HttpClient = void 0;
const errors_js_1 = require("./errors.js");
const params_js_1 = require("./params.js");
const retry_js_1 = require("./retry.js");
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_RETRIES = 2;
const LOG_LEVEL_PRIORITY = {
none: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
};
function createInternalLogger(logger, level) {
const threshold = LOG_LEVEL_PRIORITY[level];
const shouldLog = (target) => logger !== undefined && LOG_LEVEL_PRIORITY[target] <= threshold;
return {
debug(msg) {
if (shouldLog('debug'))
logger.debug(msg);
},
info(msg) {
if (shouldLog('info'))
logger.info(msg);
},
warn(msg) {
if (shouldLog('warn'))
logger.warn(msg);
},
error(msg) {
if (shouldLog('error'))
logger.error(msg);
},
};
}
function joinUrl(baseUrl, path) {
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${trimmedBase}${normalizedPath}`;
}
function buildQueryString(query) {
// Use the same flattening/snake_case rules as the body so nested params and
// booleans are encoded consistently.
const flat = (0, params_js_1.flattenParams)(query);
const params = new URLSearchParams();
for (const [key, value] of Object.entries(flat)) {
// Blobs can't go in a URL query string. Skip them rather than emitting
// "[object Blob]"; if the caller wanted a file upload they should pass it
// in the body, not the query.
if (value instanceof Blob)
continue;
params.append(key, String(value));
}
const str = params.toString();
return str.length > 0 ? `?${str}` : '';
}
function isPlainObject(value) {
return (typeof value === 'object' && value !== null && !Array.isArray(value));
}
/**
* Walks an object/array tree to detect any Blob value (e.g. a file upload).
* Used by the request layer to decide between form-urlencoded and multipart.
*/
function containsBlob(value) {
if (value instanceof Blob)
return true;
if (Array.isArray(value))
return value.some(containsBlob);
if (isPlainObject(value))
return Object.values(value).some(containsBlob);
return false;
}
/**
* Builds a multipart/form-data FormData from a request body. Uses the same
* flattening pass as the form-urlencoded path so nested Blobs (e.g. an array
* of receipts) ride correctly under their flattened keys.
*
* File-class Blobs preserve their `name` property; bare Blobs get a generic
* "blob" filename so the API doesn't reject them as missing-filename.
*/
function buildMultipartBody(body) {
const form = new FormData();
const flat = (0, params_js_1.flattenParams)(body);
for (const [key, value] of Object.entries(flat)) {
if (value instanceof Blob) {
const filename =
// File extends Blob and has its own `name` property.
value.name ?? defaultBlobFilename(value);
form.append(key, value, filename);
}
else {
form.append(key, String(value));
}
}
return form;
}
/** Pick a sensible default filename for a bare Blob based on its MIME type. */
function defaultBlobFilename(blob) {
const subtype = blob.type.split('/')[1]?.split(';')[0]?.trim();
if (subtype !== undefined && subtype.length > 0) {
return `blob.${subtype}`;
}
return 'blob';
}
/** Replace the Authorization header's value with a placeholder for safe logging. */
function redactAuthHeader(headers) {
const out = { ...headers };
if ('Authorization' in out) {
out['Authorization'] = 'Bearer [REDACTED]';
}
return out;
}
/** Convert a Headers instance to a plain object with lowercased keys. */
function headersToObject(headers) {
const out = {};
headers.forEach((value, key) => {
out[key.toLowerCase()] = value;
});
return out;
}
/**
* Extracts an error message from a Splitwise response body. Used in two
* contexts:
* - 4xx/5xx responses where the body is known to be an error envelope
* (`includeFallbacks: true` -- looser, falls back to a top-level
* `message` field if nothing else matches)
* - 2xx responses where we're sniffing for embedded errors on endpoints
* that return 200-with-success:false (`includeFallbacks: false` --
* stricter, only triggers on Splitwise's actual error envelope shapes)
*
* Splitwise's known error shapes (from the OpenAPI spec):
* { errors: { base: ["msg", ...] } } (most common)
* { errors: { fieldname: ["msg", ...] } } (per-field validation)
* { errors: ["msg", ...] } (rare; flat array)
* { error: "msg" } (singular, used by /create_friend)
*
* The 2xx path deliberately does NOT match a top-level `message` field
* because Splitwise doesn't use it as an error indicator -- treating any
* 200 body containing `message` as an error would false-positive on
* legitimate response shapes that happen to include that key.
*/
function extractErrorsFromBody(body, options = {}) {
if (!isPlainObject(body))
return null;
const errors = body['errors'];
if (Array.isArray(errors) && errors.length > 0) {
return {
message: errors.filter((e) => typeof e === 'string').join('; ') || 'Request failed',
code: 'errors',
};
}
if (isPlainObject(errors)) {
const messages = [];
for (const value of Object.values(errors)) {
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === 'string')
messages.push(item);
}
}
else if (typeof value === 'string') {
messages.push(value);
}
}
if (messages.length > 0) {
return { message: messages.join('; '), code: 'errors' };
}
}
if (typeof body['error'] === 'string') {
return { message: body['error'], code: 'error' };
}
// Top-level `message` is only consulted on the non-2xx path. On 2xx it
// would false-positive on any response that happens to include the key.
if (options.includeFallbacks === true && typeof body['message'] === 'string') {
return { message: body['message'], code: 'error' };
}
return null;
}
// ---------------------------------------------------------------------------
// HttpClient
// ---------------------------------------------------------------------------
class HttpClient {
baseUrl;
getAccessToken;
fetchImpl;
timeout;
maxRetries;
logger;
userAgent;
hooks;
constructor(config) {
this.baseUrl = config.baseUrl;
this.getAccessToken = config.getAccessToken;
// Bind to globalThis so the default fetch keeps the right `this`.
this.fetchImpl =
config.fetch ?? (globalThis.fetch.bind(globalThis));
this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
this.logger = createInternalLogger(config.logger, config.logLevel ?? 'none');
this.userAgent = config.userAgent;
this.hooks = config.hooks ?? {};
}
get(path, options) {
return this.request('GET', path, options);
}
post(path, options) {
return this.request('POST', path, options);
}
put(path, options) {
return this.request('PUT', path, options);
}
delete(path, options) {
return this.request('DELETE', path, options);
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
async request(method, path, options = {}) {
const maxRetries = options.maxRetries ?? this.maxRetries;
const callerSignal = options.signal;
// Don't burn through retries if the caller has already given up.
const shouldRetry = (ctx) => {
if (callerSignal?.aborted === true)
return false;
return (0, retry_js_1.defaultShouldRetry)(ctx);
};
let attempt = 0;
return (0, retry_js_1.withRetry)(() => {
attempt += 1;
return this.requestOnce(method, path, options, attempt);
}, { maxRetries }, shouldRetry).catch((error) => {
if (error instanceof Error) {
this.logger.error(`${method} ${path} failed: ${error.message}`);
}
throw error;
});
}
async requestOnce(method, path, options, attempt) {
const baseUrl = options.baseUrl ?? this.baseUrl;
const timeout = options.timeout ?? this.timeout;
const queryString = options.query !== undefined ? buildQueryString(options.query) : '';
const url = `${joinUrl(baseUrl, path)}${queryString}`;
const token = await this.getAccessToken();
const headers = {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
};
if (this.userAgent !== undefined) {
headers['User-Agent'] = this.userAgent;
}
let body;
if (options.body !== undefined && method !== 'GET') {
const useForm = options.formEncoded !== false;
if (useForm && containsBlob(options.body)) {
// The body has a file (e.g. an expense receipt). Send as multipart so
// fetch can include the binary payload. Don't set Content-Type — fetch
// attaches it automatically with the boundary.
body = buildMultipartBody(options.body);
}
else if (useForm) {
const flat = (0, params_js_1.flattenParams)(options.body);
const params = new URLSearchParams();
for (const [key, value] of Object.entries(flat)) {
params.append(key, String(value));
}
body = params.toString();
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
else {
body = JSON.stringify((0, params_js_1.keysToSnakeCase)(options.body));
headers['Content-Type'] = 'application/json';
}
}
this.logger.debug(`${method} ${url}`);
this.fireHook('onRequest', () => ({
method,
url,
headers: redactAuthHeader(headers),
attempt,
}));
// Combine the timeout-driven AbortController with any caller-supplied
// signal. We can't use AbortSignal.any() because it's Node 20+; instead,
// wire up a manual listener that aborts our controller when the caller
// signal fires.
const controller = new AbortController();
const timeoutHandle = setTimeout(() => {
controller.abort();
}, timeout);
const callerSignal = options.signal;
let abortListenerCleanup;
if (callerSignal !== undefined) {
if (callerSignal.aborted) {
controller.abort();
}
else {
const onAbort = () => {
controller.abort();
};
callerSignal.addEventListener('abort', onAbort);
abortListenerCleanup = () => {
callerSignal.removeEventListener('abort', onAbort);
};
}
}
const startedAt = Date.now();
let response;
try {
response = await this.fetchImpl(url, {
method,
headers,
body,
signal: controller.signal,
});
}
catch (error) {
const err = error;
let wrapped;
// AbortError fires both on timeout and on caller-initiated abort. We
// distinguish them by checking which side actually pulled the trigger.
if (err.name === 'AbortError') {
wrapped =
callerSignal?.aborted === true
? new errors_js_1.SplitwiseConnectionError('Request aborted by caller', err)
: new errors_js_1.SplitwiseConnectionError(`Request timed out after ${timeout}ms`, err);
}
else {
wrapped = new errors_js_1.SplitwiseConnectionError(err.message || 'Network request failed', err);
}
this.fireHook('onError', () => ({
method,
url,
error: wrapped,
durationMs: Date.now() - startedAt,
attempt,
}));
throw wrapped;
}
finally {
clearTimeout(timeoutHandle);
abortListenerCleanup?.();
}
const durationMs = Date.now() - startedAt;
this.logger.debug(`${method} ${url} -> ${response.status}`);
this.fireHook('onResponse', () => ({
method,
url,
status: response.status,
headers: headersToObject(response.headers),
durationMs,
attempt,
}));
try {
return await this.handleResponse(response, options.unwrapKey, options.bypassEmbeddedErrors === true);
}
catch (error) {
this.fireHook('onError', () => ({
method,
url,
error,
durationMs: Date.now() - startedAt,
attempt,
}));
throw error;
}
}
/**
* Calls a hook if registered. Wraps the event-builder in a function so we
* skip the work entirely when no hook is registered. Catches synchronous
* throws so misbehaving user code doesn't break the request.
*/
fireHook(name, buildEvent) {
const hook = this.hooks[name];
if (hook === undefined)
return;
try {
// Type assertion needed because TS can't narrow the union of event types.
hook(buildEvent());
}
catch (err) {
this.logger.error(`${name} hook threw: ${err instanceof Error ? err.message : String(err)}`);
}
}
async handleResponse(response, unwrapKey, bypassEmbeddedErrors) {
const rawText = await response.text();
let parsed = undefined;
if (rawText.length > 0) {
try {
parsed = JSON.parse(rawText);
}
catch {
// Non-JSON body. We keep `parsed` undefined and use the raw text in
// any error messages below.
}
}
if (!response.ok) {
const fromBody = extractErrorsFromBody(parsed, { includeFallbacks: true });
const message = fromBody?.message ??
`HTTP ${response.status} ${response.statusText || ''}`.trim();
const code = fromBody?.code ?? `http_${response.status}`;
throw (0, errors_js_1.createApiError)(response.status, message, code, parsed ?? rawText, response.headers);
}
// Splitwise's "destructive" endpoints (delete_*, undelete_*, addUser,
// removeUser) and some create/update endpoints can return 200 with
// success:false or a non-empty errors field when the operation can't
// happen for a domain reason (e.g. deleting a friend with unsettled
// debts). Surface these as a typed exception so callers can distinguish
// "domain failure" from successful results without inspecting the body.
if (!bypassEmbeddedErrors && isPlainObject(parsed)) {
// Only the strict error shapes here -- a legitimate 200 with a
// top-level `message` field is not an error.
const embedded = extractErrorsFromBody(parsed);
const explicitFailure = parsed['success'] === false;
if (embedded !== null || explicitFailure) {
const message = embedded?.message ??
'Splitwise reported the operation as unsuccessful';
const code = embedded?.code ?? 'success_false';
throw new errors_js_1.SplitwiseConstraintError(message, code, parsed);
}
}
const camelCased = (0, params_js_1.keysToCamelCase)(parsed);
if (unwrapKey !== undefined) {
if (isPlainObject(camelCased) && unwrapKey in camelCased) {
return camelCased[unwrapKey];
}
// Key not present — return undefined cast as T so callers expecting
// optional fields don't get a runtime error.
return undefined;
}
return camelCased;
}
}
exports.HttpClient = HttpClient;
//# sourceMappingURL=http.js.map