UNPKG

splitwise

Version:

A TypeScript SDK for the Splitwise API.

450 lines 17.8 kB
"use strict"; /** * 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