splitwise
Version:
A TypeScript SDK for the Splitwise API.
162 lines • 6.55 kB
JavaScript
;
/**
* Shared internals for the OAuth token endpoint:
* - POSTing form-encoded credentials
* - Parsing the response into an OAuthToken
* - Translating HTTP/network failures into SDK error types
*
* Both client-credentials and authorization-code flows hit the same endpoint
* with the same response shape, so they share this code path.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_AUTHORIZE_URL = exports.DEFAULT_TOKEN_URL = void 0;
exports.postTokenRequest = postTokenRequest;
const errors_js_1 = require("../errors.js");
const retry_js_1 = require("../retry.js");
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_RETRIES = 2;
exports.DEFAULT_TOKEN_URL = 'https://secure.splitwise.com/oauth/token';
exports.DEFAULT_AUTHORIZE_URL = 'https://secure.splitwise.com/oauth/authorize';
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function extractOAuthErrorMessage(body) {
if (!isPlainObject(body))
return null;
const errorDescription = body['error_description'];
if (typeof errorDescription === 'string' && errorDescription.length > 0) {
return errorDescription;
}
const error = body['error'];
if (typeof error === 'string' && error.length > 0) {
return error;
}
return null;
}
function extractOAuthErrorCode(body) {
if (isPlainObject(body) && typeof body['error'] === 'string') {
return body['error'];
}
return 'oauth_error';
}
/**
* POSTs `params` form-encoded to the token endpoint and returns the parsed token.
*
* 401/400 responses are mapped to SplitwiseAuthenticationError because the OAuth
* spec uses 400 for things like `invalid_grant` even though the SDK normally
* reserves 400 for validation errors.
*
* Honors timeout, AbortSignal, and exponential-backoff retry on transient
* failures (matching the main HttpClient's behavior). 4xx responses are not
* retried since they indicate the credentials themselves are bad.
*/
async function postTokenRequest(params, options = {}) {
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
const callerSignal = options.signal;
return (0, retry_js_1.withRetry)(() => postTokenRequestOnce(params, options), { maxRetries }, ({ error }) => {
// Don't burn through retries if the caller has given up.
if (callerSignal?.aborted === true)
return false;
// Network failures, 5xx, and rate-limit responses are transient.
// 4xx (bad credentials) are not -- they indicate the request itself
// is wrong and retrying won't help.
return (error instanceof errors_js_1.SplitwiseConnectionError ||
error instanceof errors_js_1.SplitwiseRateLimitError ||
error instanceof errors_js_1.SplitwiseServerError);
});
}
async function postTokenRequestOnce(params, options) {
const tokenUrl = options.tokenUrl ?? exports.DEFAULT_TOKEN_URL;
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
const fetchImpl = options.fetch ??
globalThis.fetch.bind(globalThis);
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
body.append(key, value);
}
// Compose the timeout-driven controller with any caller-supplied signal,
// matching HttpClient.requestOnce.
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);
};
}
}
let response;
try {
response = await fetchImpl(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
signal: controller.signal,
});
}
catch (error) {
const err = error;
if (err.name === 'AbortError') {
if (callerSignal?.aborted === true) {
throw new errors_js_1.SplitwiseConnectionError('OAuth token request aborted by caller', err);
}
throw new errors_js_1.SplitwiseConnectionError(`OAuth token request timed out after ${timeout}ms`, err);
}
throw new errors_js_1.SplitwiseConnectionError(err.message || 'Network request failed', err);
}
finally {
clearTimeout(timeoutHandle);
abortListenerCleanup?.();
}
const rawText = await response.text();
let parsed = undefined;
if (rawText.length > 0) {
try {
parsed = JSON.parse(rawText);
}
catch {
// Non-JSON body; leave parsed undefined and fall back to raw text below.
}
}
if (!response.ok) {
const message = extractOAuthErrorMessage(parsed) ??
`HTTP ${response.status} ${response.statusText || ''}`.trim();
const code = extractOAuthErrorCode(parsed);
if (response.status === 400 || response.status === 401) {
throw new errors_js_1.SplitwiseAuthenticationError(message, code, parsed ?? rawText);
}
throw (0, errors_js_1.createApiError)(response.status, message, code, parsed ?? rawText, response.headers);
}
const body_ = parsed;
const accessToken = body_?.access_token;
const tokenType = body_?.token_type;
if (typeof accessToken !== 'string' || accessToken.length === 0) {
throw new errors_js_1.SplitwiseAuthenticationError('OAuth token response missing access_token', 'invalid_response', parsed ?? rawText);
}
const token = {
accessToken,
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
};
if (typeof body_?.expires_in === 'number' && Number.isFinite(body_.expires_in)) {
token.expiresAt = Date.now() + body_.expires_in * 1000;
}
if (typeof body_?.refresh_token === 'string' && body_.refresh_token.length > 0) {
token.refreshToken = body_.refresh_token;
}
return token;
}
//# sourceMappingURL=internal.js.map