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.
  }
}