Spaces:
Runtime error
Runtime error
File size: 5,664 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | /**
* Injectable HTTP transport for the OpenWA SDK.
*
* The client never calls `globalThis.fetch` directly. Instead it accepts a
* `FetchLike` implementation (defaulting to the global `fetch`). This makes the
* SDK trivially testable — a test passes a recorder as `fetch` instead of
* monkey-patching globals — and lets consumers intercept/observability-wrap
* outbound calls.
*
* @packageDocumentation
*/
import { classifyApiError, OpenWAApiError, OpenWATimeoutError } from './errors.js';
/** Subset of the WHATWG `fetch` signature the SDK relies on. */
export type FetchLike = typeof globalThis.fetch;
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface RequestOptions {
method: HttpMethod;
/** Full path beginning with `/`, e.g. `/api/sessions`. */
path: string;
/** Query parameters, serialized into the URL. */
query?: object;
/** JSON-serializable request body. */
body?: unknown;
/** Override the per-client timeout (ms) for this single request. */
timeoutMs?: number;
/** Extra headers merged on top of the client defaults (auth/JSON win). */
headers?: Record<string, string>;
}
export interface ClientConfig {
/** Base URL of the OpenWA API, e.g. `http://localhost:2785`. */
baseUrl: string;
/** API key sent as `X-API-Key`. */
apiKey: string;
/** Per-request timeout in milliseconds (default 30000). */
timeoutMs?: number;
/** Default headers applied to every request. */
defaultHeaders?: Record<string, string>;
/** Injectable transport; defaults to the global `fetch`. */
fetch?: FetchLike;
}
/**
* Percent-encode a single path segment (e.g. a chat/message id) so a value
* containing `/`, `#`, `?` or whitespace can't break out of its path position.
* WhatsApp-id characters that are already path-safe (`@`, `:`, `+`) are kept
* readable.
*/
export function encodeSegment(segment: string | number): string {
return encodeURIComponent(String(segment)).replace(/%40/g, '@').replace(/%3A/g, ':').replace(/%2B/g, '+');
}
/** Build a URL with serialized query params, omitting `undefined`/`null` values. */
export function buildUrl(baseUrl: string, path: string, query?: object): string {
const url = `${baseUrl.replace(/\/$/, '')}${path}`;
if (!query) return url;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
params.append(key, String(value));
}
const qs = params.toString();
return qs ? `${url}?${qs}` : url;
}
/**
* Perform a single request against the OpenWA API and return the parsed JSON
* body (or `null` for 204). Throws a typed {@link OpenWAApiError} subclass on
* non-2xx, or {@link OpenWATimeoutError} on timeout.
*/
export async function request<T>(
config: Required<Omit<ClientConfig, 'fetch'>> & { fetch: FetchLike },
options: RequestOptions,
): Promise<T> {
const url = buildUrl(config.baseUrl, options.path, options.query);
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Auth and JSON content-type WIN over caller-supplied defaults/per-request headers — the SDK only
// ever sends a JSON body, and this matches the Python and PHP SDKs (which force JSON) and the
// documented "JSON headers win" contract. Put them last so a defaultHeaders Content-Type can't clobber.
const headers: Record<string, string> = {
...config.defaultHeaders,
...options.headers,
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
};
try {
const res = await config.fetch(url, {
method: options.method,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
// Never auto-follow redirects: doing so would re-send the X-API-Key header
// to the redirect target (potentially a different origin). A 3xx surfaces
// as a non-2xx error instead.
redirect: 'manual',
});
if (!res.ok) {
const context = `${options.method} ${options.path}`;
const apiError = await OpenWAApiError.fromResponse(res, context);
throw classifyApiError(apiError.status, apiError.message, apiError.body, apiError.errorKind);
}
if (res.status === 204) {
return null as T;
}
const text = await res.text();
if (!text) return null as T;
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
throw new OpenWATimeoutError(timeoutMs);
}
throw err;
} finally {
clearTimeout(timer);
}
}
const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
/**
* Warn (NOT throw) when a URL is `http://` and the host is not localhost. The API key is sent as
* an `X-API-Key` header on every request — over plaintext http to a non-local host that's cleartext
* on the wire. Warning (not refusing) keeps local dev and TLS-terminating-proxy topologies working.
*/
export function warnIfInsecureHttpUrl(url: string, label = 'baseUrl'): void {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' && !LOCALHOST_HOSTS.has(parsed.hostname.toLowerCase())) {
console.warn(
`[OpenWA SDK] ${label} uses an insecure http:// URL (host: ${parsed.hostname}). ` +
'The API key will be sent in cleartext. Use https:// in production.',
);
}
} catch {
// Unparseable — the request will fail downstream with a clear error.
}
}
|