127 lines
5.4 KiB
JavaScript
127 lines
5.4 KiB
JavaScript
'use strict';
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* Overflow API client — the Tap part of Overflow's public REST API.
|
|||
|
|
*
|
|||
|
|
* https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json)
|
|||
|
|
*
|
|||
|
|
* Authentication is two headers, `x-client-id` and `x-api-key`, from the
|
|||
|
|
* Overflow Dashboard's Developers › API Keys. 120 requests per minute per
|
|||
|
|
* client; a 429 is answered when the window is spent.
|
|||
|
|
*
|
|||
|
|
* Reads used here are documented: GET /api/v3/tap/destinations,
|
|||
|
|
* GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the
|
|||
|
|
* `destinationId` it currently points at).
|
|||
|
|
*
|
|||
|
|
* THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only
|
|||
|
|
* for Tap; Overflow's own ProPresenter helper changes a group's destination
|
|||
|
|
* through the Dashboard's private API after signing in with a Dashboard
|
|||
|
|
* login. `setGroupDestination` below sends the natural REST shape for this
|
|||
|
|
* API — PATCH the group with the new `destinationId`, as PATCH /donors/{id}
|
|||
|
|
* updates a donor — and the server confirms every switch by reading the group
|
|||
|
|
* back, so an answer that changes nothing is reported, not believed. If
|
|||
|
|
* Overflow publishes (or confirms) a different route, this one function is
|
|||
|
|
* the only place to change. tools/overflow-tap-mock.js serves the same shape.
|
|||
|
|
*
|
|||
|
|
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
|||
|
|
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|||
|
|
const TIMEOUT_MS = 10000;
|
|||
|
|
const PAGE_SIZE = 100;
|
|||
|
|
const MAX_PAGES = 20;
|
|||
|
|
const ID_RE = /^[0-9a-f]{24}$/i;
|
|||
|
|
|
|||
|
|
class OverflowError extends Error {
|
|||
|
|
constructor(message, { status = 0, retryable = false } = {}) {
|
|||
|
|
super(message);
|
|||
|
|
this.name = 'OverflowError';
|
|||
|
|
this.status = status;
|
|||
|
|
this.retryable = retryable;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function looksLikeId(s) {
|
|||
|
|
return ID_RE.test(String(s || '').trim());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) {
|
|||
|
|
const id = String(clientId || '').trim();
|
|||
|
|
const key = String(apiKey || '').trim();
|
|||
|
|
|
|||
|
|
async function request(method, path, { query, body } = {}) {
|
|||
|
|
if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 });
|
|||
|
|
const url = new URL(API_BASE + path);
|
|||
|
|
for (const [k, v] of Object.entries(query || {})) {
|
|||
|
|
if (v === undefined || v === null || v === '') continue;
|
|||
|
|
url.searchParams.set(k, String(v));
|
|||
|
|
}
|
|||
|
|
const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key };
|
|||
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|||
|
|
const ctl = new AbortController();
|
|||
|
|
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|||
|
|
let res;
|
|||
|
|
try {
|
|||
|
|
res = await fetchImpl(url, {
|
|||
|
|
method,
|
|||
|
|
headers,
|
|||
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|||
|
|
signal: ctl.signal,
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
const aborted = err && err.name === 'AbortError';
|
|||
|
|
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
|||
|
|
throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true });
|
|||
|
|
} finally {
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
}
|
|||
|
|
const text = await res.text();
|
|||
|
|
let data = null;
|
|||
|
|
if (text) {
|
|||
|
|
try { data = JSON.parse(text); } catch { data = null; }
|
|||
|
|
}
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const detail = data && (data.message || data.error || data.detail);
|
|||
|
|
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText);
|
|||
|
|
let message;
|
|||
|
|
if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`;
|
|||
|
|
else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`;
|
|||
|
|
else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)';
|
|||
|
|
else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`;
|
|||
|
|
throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 });
|
|||
|
|
}
|
|||
|
|
return data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Every page of a collection, newest-first as the API sorts it. */
|
|||
|
|
async function listAll(path, query = {}) {
|
|||
|
|
const all = [];
|
|||
|
|
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|||
|
|
const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } });
|
|||
|
|
const data = Array.isArray(res && res.data) ? res.data : [];
|
|||
|
|
all.push(...data);
|
|||
|
|
const total = Number(res && res.totalCount);
|
|||
|
|
if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break;
|
|||
|
|
}
|
|||
|
|
return all;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
base: API_BASE,
|
|||
|
|
configured: Boolean(id && key),
|
|||
|
|
listDestinations: () => listAll('/api/v3/tap/destinations'),
|
|||
|
|
listGroups: () => listAll('/api/v3/tap/groups'),
|
|||
|
|
async getGroup(groupId) {
|
|||
|
|
const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`);
|
|||
|
|
return res && res.data ? res.data : res;
|
|||
|
|
},
|
|||
|
|
/** See the note at the top of this file: the one unverified call. */
|
|||
|
|
setGroupDestination(groupId, destinationId) {
|
|||
|
|
return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } });
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE };
|