Files
ProdDash/modules/overflow-tap/overflow.js
T

127 lines
5.4 KiB
JavaScript
Raw Normal View History

'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 };