splitwise
Version:
A TypeScript SDK for the Splitwise API.
369 lines • 15.9 kB
JavaScript
/**
* 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',
* });
* ```
*/
import { createAuthorizationUrl, exchangeAuthorizationCode, } from './auth/authorization-code.js';
import { fetchClientCredentialsToken } from './auth/client-credentials.js';
import { SplitwiseAuthenticationError } from './errors.js';
import { HttpClient, } from './http.js';
import { SDK_VERSION } from './version.js';
import { Categories } from './resources/categories.js';
import { Comments } from './resources/comments.js';
import { Currencies } from './resources/currencies.js';
import { Expenses } from './resources/expenses.js';
import { Friends } from './resources/friends.js';
import { Groups } from './resources/groups.js';
import { Notifications } from './resources/notifications.js';
import { Users } from './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/${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}`;
}
export 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 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(this.http);
this.groups = new Groups(this.http);
this.users = new Users(this.http);
this.friends = new Friends(this.http);
this.comments = new Comments(this.http);
this.notifications = new Notifications(this.http);
this.currencies = new Currencies(this.http);
this.categories = new 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 SplitwiseAuthenticationError('No cached token available for an authorization-code client', 'no_token', null);
}
if (isTokenExpired(this.cachedToken)) {
throw new 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 = 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 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 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;
}
}
// 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