UNPKG

splitwise

Version:

A TypeScript SDK for the Splitwise API.

373 lines 16.3 kB
"use strict"; /** * Splitwise SDK v2 client. * * Resource-namespaced client for the Splitwise API. Supports two OAuth flows * (Client Credentials for app-owner access, Authorization Code + PKCE for * end-user access), automatic retries, and zero runtime dependencies. * * @example * ```typescript * // Client Credentials (app owner's data) * const sw = new Splitwise({ consumerKey: '...', consumerSecret: '...' }); * const expenses = await sw.expenses.list({ groupId: 123 }); * * // Authorization Code with PKCE (end-user data) * const auth = await Splitwise.createAuthorizationUrl({ * clientId: '...', redirectUri: 'http://localhost:3000/callback', * }); * // ...redirect user to auth.url, capture `code` from callback... * const sw = await Splitwise.fromAuthorizationCode({ * clientId: '...', clientSecret: '...', * code, codeVerifier: auth.codeVerifier, * redirectUri: 'http://localhost:3000/callback', * }); * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.Splitwise = void 0; const authorization_code_js_1 = require("./auth/authorization-code.js"); const client_credentials_js_1 = require("./auth/client-credentials.js"); const errors_js_1 = require("./errors.js"); const http_js_1 = require("./http.js"); const version_js_1 = require("./version.js"); const categories_js_1 = require("./resources/categories.js"); const comments_js_1 = require("./resources/comments.js"); const currencies_js_1 = require("./resources/currencies.js"); const expenses_js_1 = require("./resources/expenses.js"); const friends_js_1 = require("./resources/friends.js"); const groups_js_1 = require("./resources/groups.js"); const notifications_js_1 = require("./resources/notifications.js"); const users_js_1 = require("./resources/users.js"); const DEFAULT_BASE_URL = 'https://secure.splitwise.com/api/v3.0'; /** * Internal sigil used by `fromAuthorizationCode` to mark a client whose * cached token came from a one-shot OAuth exchange (and therefore can't be * automatically refreshed). Prevents the user-facing `accessToken` config * option from triggering this special path. */ const FROM_AUTHORIZATION_CODE = Symbol('FROM_AUTHORIZATION_CODE'); const ALLOWED_CONFIG_KEYS = new Set([ 'consumerKey', 'consumerSecret', 'accessToken', 'baseUrl', 'maxRetries', 'timeout', 'logger', 'logLevel', 'fetch', 'hooks', 'appInfo', ]); function buildUserAgent(appInfo) { const base = `splitwise-node/${version_js_1.SDK_VERSION}`; if (appInfo === undefined) return base; let app = appInfo.name; if (appInfo.version !== undefined) app += `/${appInfo.version}`; if (appInfo.url !== undefined) app += ` (${appInfo.url})`; return `${base} ${app}`; } class Splitwise { expenses; groups; users; friends; comments; notifications; currencies; categories; http; config; fetchImpl; cachedToken = null; /** * Holds an in-flight token fetch so concurrent first-call requests share a * single network call instead of stampeding the OAuth endpoint. */ inFlightTokenFetch = null; /** * Tracks where the active token came from. Determines what happens when * `cachedToken` expires: * - 'static' : the user passed `accessToken` directly; we * have no way to refresh, just keep using it. * - 'client_credentials' : we fetched it from the OAuth endpoint and * can fetch another one. * - 'authorization_code' : it came from fromAuthorizationCode(); there * is no automatic refresh path (Splitwise * doesn't issue refresh_tokens), so an * expired token is a hard error. */ tokenSource; constructor(config) { validateConfig(config); this.config = config; this.fetchImpl = config.fetch; // Determine where we'll source tokens from. The internal sigil is // checked separately because fromAuthorizationCode() also passes a // user-facing accessToken to satisfy validateConfig(). const fromAuthCode = config[FROM_AUTHORIZATION_CODE]; if (fromAuthCode === true) { this.tokenSource = 'authorization_code'; } else if (config.accessToken !== undefined) { this.tokenSource = 'static'; } else { this.tokenSource = 'client_credentials'; } this.http = new http_js_1.HttpClient({ baseUrl: config.baseUrl ?? DEFAULT_BASE_URL, getAccessToken: () => this.getAccessToken(), userAgent: buildUserAgent(config.appInfo), ...(this.fetchImpl !== undefined && { fetch: this.fetchImpl }), ...(config.timeout !== undefined && { timeout: config.timeout }), ...(config.maxRetries !== undefined && { maxRetries: config.maxRetries }), ...(config.logger !== undefined && { logger: config.logger }), ...(config.logLevel !== undefined && { logLevel: config.logLevel }), ...(config.hooks !== undefined && { hooks: config.hooks }), }); this.expenses = new expenses_js_1.Expenses(this.http); this.groups = new groups_js_1.Groups(this.http); this.users = new users_js_1.Users(this.http); this.friends = new friends_js_1.Friends(this.http); this.comments = new comments_js_1.Comments(this.http); this.notifications = new notifications_js_1.Notifications(this.http); this.currencies = new currencies_js_1.Currencies(this.http); this.categories = new categories_js_1.Categories(this.http); } // --------------------------------------------------------------------------- // Top-level utility methods (not natural fits for any resource) // --------------------------------------------------------------------------- /** * Returns identifying info about the authenticated client. Useful as a * smoke test ("am I authenticated?") and for confirming which app/token * the SDK is using. * * Despite the name, the endpoint is closer to a `whoami` than a generic * health check. */ async test(overrides) { return this.http.get('/test', overrides); } /** * Parse a natural-language expense description (e.g. "I owe Bob $10"). * * Unlike most endpoints, parse_sentence reports parse failures via the * `valid` and `error` response fields rather than HTTP errors, so this * method intentionally bypasses the SDK's "errors-in-body throw" check. * Inspect `response.valid` and `response.error` after the call. */ async parseSentence(params, overrides) { return this.http.post('/parse_sentence', { body: { ...params }, bypassEmbeddedErrors: true, ...overrides, }); } /** Bulk fetch of user, groups, friends, currencies, categories, etc. */ async getMainData(params, overrides) { return this.http.get('/get_main_data', { ...(params !== undefined && { query: { ...params } }), ...overrides, }); } /** * Escape hatch for endpoints not (yet) covered by the typed resource API. * * Goes through the same pipeline as the typed methods (auth, retries, * camelCase conversion, hooks, error mapping), so you don't lose those * niceties — but you have to know the path/shape yourself. * * @example * ```ts * const result = await sw.rawRequest<MyShape>( * 'GET', * '/some_undocumented_endpoint', * { query: { limit: 10 } }, * ); * ``` */ async rawRequest(method, path, options) { switch (method) { case 'GET': return this.http.get(path, options); case 'POST': return this.http.post(path, options); case 'PUT': return this.http.put(path, options); case 'DELETE': return this.http.delete(path, options); } } // --------------------------------------------------------------------------- // Token management // --------------------------------------------------------------------------- /** * Returns a valid access token, fetching one via Client Credentials if * necessary. Useful for callers who want to obtain a token once and persist * it across process restarts (then pass it back as `accessToken`). * * Concurrent calls share a single in-flight fetch (no thundering herd). */ async getAccessToken() { // Static accessToken: hand it back. We have no refresh path, no cached // token to compare expiry against -- whatever the user gave us is what // we use, even if it's expired (the API will tell them). if (this.tokenSource === 'static') { return this.config.accessToken; } // Authorization Code: the token came from a one-shot exchange. Use it // until expiry; after expiry there's nothing we can do automatically // (Splitwise doesn't issue refresh_tokens), so throw a clear error // explaining the situation. if (this.tokenSource === 'authorization_code') { if (this.cachedToken === null) { // Defensive: shouldn't happen since fromAuthorizationCode sets it. throw new errors_js_1.SplitwiseAuthenticationError('No cached token available for an authorization-code client', 'no_token', null); } if (isTokenExpired(this.cachedToken)) { throw new errors_js_1.SplitwiseAuthenticationError('Authorization-code token has expired and cannot be refreshed automatically. ' + 'Re-run the Authorization Code flow to obtain a new token.', 'token_expired', { expiresAt: this.cachedToken.expiresAt ?? null }); } return this.cachedToken.accessToken; } // Client Credentials: cached if fresh, refetch if stale or absent. if (this.cachedToken !== null && !isTokenExpired(this.cachedToken)) { return this.cachedToken.accessToken; } // If another caller already kicked off a token fetch, wait on theirs // instead of starting a second one. if (this.inFlightTokenFetch !== null) { const token = await this.inFlightTokenFetch; return token.accessToken; } // We've already verified consumerKey and consumerSecret in validateConfig // when accessToken is absent. const fetchOptions = { ...(this.fetchImpl !== undefined && { fetch: this.fetchImpl }), ...(this.config.timeout !== undefined && { timeout: this.config.timeout }), ...(this.config.maxRetries !== undefined && { maxRetries: this.config.maxRetries, }), }; this.inFlightTokenFetch = (0, client_credentials_js_1.fetchClientCredentialsToken)({ clientId: this.config.consumerKey, clientSecret: this.config.consumerSecret, }, fetchOptions); try { const token = await this.inFlightTokenFetch; this.cachedToken = token; return token.accessToken; } finally { this.inFlightTokenFetch = null; } } // --------------------------------------------------------------------------- // Static auth helpers (Authorization Code + PKCE flow) // --------------------------------------------------------------------------- /** * Generate an authorization URL for the OAuth Authorization Code + PKCE flow. * Returns the URL plus the `state` and `codeVerifier` values your application * must persist (e.g. in a session) to complete the exchange. */ static async createAuthorizationUrl(params) { return (0, authorization_code_js_1.createAuthorizationUrl)(params); } /** * Exchange an authorization code for an access token, then return a fully * configured Splitwise client using that token. * * The full OAuthToken (including `expiresAt` and `refreshToken` if Splitwise * provides them) is stored on the client; you can read it back via * `sw.getOAuthToken()` to persist for later use. */ static async fromAuthorizationCode(params, config) { const fetchImpl = config?.fetch; const tokenOptions = { ...(fetchImpl !== undefined && { fetch: fetchImpl }), ...(config?.timeout !== undefined && { timeout: config.timeout }), ...(config?.maxRetries !== undefined && { maxRetries: config.maxRetries }), }; const token = await (0, authorization_code_js_1.exchangeAuthorizationCode)(params, tokenOptions); const sw = new Splitwise({ ...config, // Pass the access token so validateConfig accepts the construction; // the cached OAuthToken below preserves expiry/refresh metadata that // a bare `accessToken` config option can't. accessToken: token.accessToken, // Internal sigil so getAccessToken() knows this client's token came // from a one-shot OAuth exchange and can't be auto-refreshed. [FROM_AUTHORIZATION_CODE]: true, }); sw.cachedToken = token; return sw; } /** * Returns the cached OAuthToken if one was obtained via Client Credentials * or `fromAuthorizationCode`, or undefined if the client was constructed * with a bare `accessToken` (no expiry metadata to share). * * Useful for persisting the token across process restarts: * * ```ts * const token = sw.getOAuthToken(); * if (token !== undefined) { * await persist(token); // store accessToken + expiresAt + refreshToken * } * ``` */ getOAuthToken() { return this.cachedToken ?? undefined; } } exports.Splitwise = Splitwise; // v1 supported these as constructor defaults; v2 dropped them. Surface a // helpful error rather than the generic "unknown option" message. const V1_DROPPED_KEYS = new Set([ 'group_id', 'user_id', 'expense_id', 'friend_id', ]); function validateConfig(config) { if (config === null || typeof config !== 'object') { throw new TypeError('Splitwise config must be an object'); } for (const key of Object.keys(config)) { if (V1_DROPPED_KEYS.has(key)) { throw new TypeError(`Splitwise v2 no longer supports the "${key}" default-ID config option from v1. ` + `Pass IDs explicitly to each method instead (e.g. sw.expenses.list({ groupId: 123 })).`); } if (!ALLOWED_CONFIG_KEYS.has(key)) { throw new TypeError(`Unknown Splitwise config option: "${key}"`); } } // Reject empty strings explicitly -- they would otherwise pass the // !== undefined check and produce a useless `Bearer ` header at request time. if (config.accessToken !== undefined && config.accessToken.length === 0) { throw new TypeError('Splitwise: accessToken cannot be an empty string'); } if (config.consumerKey !== undefined && config.consumerKey.length === 0) { throw new TypeError('Splitwise: consumerKey cannot be an empty string'); } if (config.consumerSecret !== undefined && config.consumerSecret.length === 0) { throw new TypeError('Splitwise: consumerSecret cannot be an empty string'); } const hasToken = config.accessToken !== undefined; const hasCreds = config.consumerKey !== undefined && config.consumerSecret !== undefined; if (!hasToken && !hasCreds) { throw new TypeError('Splitwise requires either an accessToken, or both consumerKey and consumerSecret'); } } function isTokenExpired(token) { if (token.expiresAt === undefined) return false; // Refresh 60s early to avoid using a token that expires mid-request return Date.now() >= token.expiresAt - 60_000; } //# sourceMappingURL=client.js.map