feat(headers): add support for custom HTTP headers in clients

* Introduced `headers` property in `ClientConfig` interface.
* Updated `HeaderSpecClient` and `ResolveSpecClient` to utilize custom headers.
* Implemented `mergeHeaders` function to handle case-insensitive header merging.
* Added tests for custom header functionality in clients.
This commit is contained in:
2026-09-23 19:47:05 +02:00
parent a220338eea
commit b587cbd3c4
11 changed files with 265 additions and 135 deletions
@@ -0,0 +1,5 @@
---
"@warkypublic/resolvespec-js": patch
---
Forward custom ClientConfig headers on every ResolveSpec and HeaderSpec request. Merge headers case-insensitively and isolate cached clients by URL and effective headers, including authentication and tenant headers.
+23 -1
View File
@@ -28,7 +28,7 @@ import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespe
// Class instantiation // Class instantiation
const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' }); const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// Or singleton factory (returns cached instance per baseUrl) // Or singleton factory (returns cached instance per baseUrl and effective headers)
const client = getResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' }); const client = getResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// Read with filters, sort, pagination // Read with filters, sort, pagination
@@ -211,3 +211,25 @@ pnpm run lint # eslint
## License ## License
MIT MIT
### Custom HTTP headers
Both `ResolveSpecClient` and `HeaderSpecClient` (including their factory functions)
accept `headers` in `ClientConfig` and send them on every HTTP request:
```typescript
const client = new ResolveSpecClient({
baseUrl: 'http://localhost:3000',
token: 'your-token',
headers: { 'X-Tenant': 'acme' },
});
```
Header names are merged case-insensitively. Custom headers override the default
`Content-Type`; a supplied `token` overrides custom `Authorization`, and HeaderSpec
query options override matching custom query headers. Without a token, custom
`Authorization` is preserved. Configuration is copied at construction; create or
retrieve a client with new configuration to change headers. Factory clients are
cached by URL and effective headers, keeping different tenants and tokens separate.
Grid adapters must forward `dataSourceOptions.headers` to this `headers` option.
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -34,6 +34,8 @@ export declare function buildHeaders(options: Options): Record<string, string>;
export declare interface ClientConfig { export declare interface ClientConfig {
baseUrl: string; baseUrl: string;
token?: string; token?: string;
/** Custom HTTP headers. Token and HeaderSpec query options take precedence. */
headers?: Record<string, string>;
} }
export declare interface Column { export declare interface Column {
+125 -110
View File
@@ -1,29 +1,47 @@
import { v4 as l } from "uuid"; import { v4 as l } from "uuid";
const d = /* @__PURE__ */ new Map(); function u(...r) {
function E(n) { const e = {};
const e = n.baseUrl; for (const t of r)
let t = d.get(e); for (const [s, n] of Object.entries(t)) {
return t || (t = new g(n), d.set(e, t)), t; for (const i of Object.keys(e))
i.toLowerCase() === s.toLowerCase() && delete e[i];
Object.defineProperty(e, s, { value: n, enumerable: !0, configurable: !0, writable: !0 });
}
return e;
} }
class g { function m(r) {
return u(
{ "Content-Type": "application/json" },
r.headers ?? {},
r.token ? { Authorization: `Bearer ${r.token}` } : {}
);
}
function p(r) {
const e = Object.entries(m(r)).map(([t, s]) => [t.toLowerCase(), s]).sort(([t], [s]) => t.localeCompare(s));
return JSON.stringify([r.baseUrl, e]);
}
const f = /* @__PURE__ */ new Map();
function v(r) {
const e = p(r);
let t = f.get(e);
return t || (t = new y(r), f.set(e, t)), t;
}
class y {
constructor(e) { constructor(e) {
this.config = e; this.config = { ...e, headers: { ...e.headers } };
} }
buildUrl(e, t, s) { buildUrl(e, t, s) {
let r = `${this.config.baseUrl}/${e}/${t}`; let n = `${this.config.baseUrl}/${e}/${t}`;
return s && (r += `/${s}`), r; return s && (n += `/${s}`), n;
} }
baseHeaders() { baseHeaders() {
const e = { return m(this.config);
"Content-Type": "application/json"
};
return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e;
} }
async fetchWithError(e, t) { async fetchWithError(e, t) {
const s = await fetch(e, t), r = await s.json(); const s = await fetch(e, t), n = await s.json();
if (!s.ok) if (!s.ok)
throw new Error(r.error?.message || "An error occurred"); throw new Error(n.error?.message || "An error occurred");
return r; return n;
} }
async getMetadata(e, t) { async getMetadata(e, t) {
const s = this.buildUrl(e, t); const s = this.buildUrl(e, t);
@@ -32,11 +50,11 @@ class g {
headers: this.baseHeaders() headers: this.baseHeaders()
}); });
} }
async read(e, t, s, r) { async read(e, t, s, n) {
const i = typeof s == "number" || typeof s == "string" ? String(s) : void 0, a = this.buildUrl(e, t, i), c = { const i = typeof s == "number" || typeof s == "string" ? String(s) : void 0, a = this.buildUrl(e, t, i), c = {
operation: "read", operation: "read",
id: Array.isArray(s) ? s : void 0, id: Array.isArray(s) ? s : void 0,
options: r options: n
}; };
return this.fetchWithError(a, { return this.fetchWithError(a, {
method: "POST", method: "POST",
@@ -44,11 +62,11 @@ class g {
body: JSON.stringify(c) body: JSON.stringify(c)
}); });
} }
async create(e, t, s, r) { async create(e, t, s, n) {
const i = this.buildUrl(e, t), a = { const i = this.buildUrl(e, t), a = {
operation: "create", operation: "create",
data: s, data: s,
options: r options: n
}; };
return this.fetchWithError(i, { return this.fetchWithError(i, {
method: "POST", method: "POST",
@@ -56,10 +74,10 @@ class g {
body: JSON.stringify(a) body: JSON.stringify(a)
}); });
} }
async update(e, t, s, r, i) { async update(e, t, s, n, i) {
const a = typeof r == "number" || typeof r == "string" ? String(r) : void 0, c = this.buildUrl(e, t, a), o = { const a = typeof n == "number" || typeof n == "string" ? String(n) : void 0, c = this.buildUrl(e, t, a), o = {
operation: "update", operation: "update",
id: Array.isArray(r) ? r : void 0, id: Array.isArray(n) ? n : void 0,
data: s, data: s,
options: i options: i
}; };
@@ -70,23 +88,23 @@ class g {
}); });
} }
async delete(e, t, s) { async delete(e, t, s) {
const r = this.buildUrl(e, t, String(s)), i = { const n = this.buildUrl(e, t, String(s)), i = {
operation: "delete" operation: "delete"
}; };
return this.fetchWithError(r, { return this.fetchWithError(n, {
method: "POST", method: "POST",
headers: this.baseHeaders(), headers: this.baseHeaders(),
body: JSON.stringify(i) body: JSON.stringify(i)
}); });
} }
} }
const f = /* @__PURE__ */ new Map(); const b = /* @__PURE__ */ new Map();
function _(n) { function O(r) {
const e = n.url; const e = r.url;
let t = f.get(e); let t = b.get(e);
return t || (t = new p(n), f.set(e, t)), t; return t || (t = new S(r), b.set(e, t)), t;
} }
class p { class S {
constructor(e) { constructor(e) {
this.ws = null, this.messageHandlers = /* @__PURE__ */ new Map(), this.subscriptions = /* @__PURE__ */ new Map(), this.eventListeners = {}, this.state = "disconnected", this.reconnectAttempts = 0, this.reconnectTimer = null, this.heartbeatTimer = null, this.isManualClose = !1, this.config = { this.ws = null, this.messageHandlers = /* @__PURE__ */ new Map(), this.subscriptions = /* @__PURE__ */ new Map(), this.eventListeners = {}, this.state = "disconnected", this.reconnectAttempts = 0, this.reconnectTimer = null, this.heartbeatTimer = null, this.isManualClose = !1, this.config = {
url: e.url, url: e.url,
@@ -110,12 +128,12 @@ class p {
this.handleMessage(s.data); this.handleMessage(s.data);
}, this.ws.onerror = (s) => { }, this.ws.onerror = (s) => {
this.log("WebSocket error:", s); this.log("WebSocket error:", s);
const r = new Error("WebSocket connection error"); const n = new Error("WebSocket connection error");
this.emit("error", r), t(r); this.emit("error", n), t(n);
}, this.ws.onclose = (s) => { }, this.ws.onclose = (s) => {
this.log("WebSocket closed:", s.code, s.reason), this.stopHeartbeat(), this.setState("disconnected"), this.emit("disconnect", s), this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts && (this.reconnectAttempts++, this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`), this.setState("reconnecting"), this.reconnectTimer = setTimeout(() => { this.log("WebSocket closed:", s.code, s.reason), this.stopHeartbeat(), this.setState("disconnected"), this.emit("disconnect", s), this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts && (this.reconnectAttempts++, this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`), this.setState("reconnecting"), this.reconnectTimer = setTimeout(() => {
this.connect().catch((r) => { this.connect().catch((n) => {
this.log("Reconnection failed:", r); this.log("Reconnection failed:", n);
}); });
}, this.config.reconnectInterval)); }, this.config.reconnectInterval));
}; };
@@ -129,8 +147,8 @@ class p {
} }
async request(e, t, s) { async request(e, t, s) {
this.ensureConnected(); this.ensureConnected();
const r = l(), i = { const n = l(), i = {
id: r, id: n,
type: "request", type: "request",
operation: e, operation: e,
entity: t, entity: t,
@@ -140,10 +158,10 @@ class p {
options: s?.options options: s?.options
}; };
return new Promise((a, c) => { return new Promise((a, c) => {
this.messageHandlers.set(r, (o) => { this.messageHandlers.set(n, (o) => {
o.success ? a(o.data) : c(new Error(o.error?.message || "Request failed")); o.success ? a(o.data) : c(new Error(o.error?.message || "Request failed"));
}), this.send(i), setTimeout(() => { }), this.send(i), setTimeout(() => {
this.messageHandlers.has(r) && (this.messageHandlers.delete(r), c(new Error("Request timeout"))); this.messageHandlers.has(n) && (this.messageHandlers.delete(n), c(new Error("Request timeout")));
}, 3e4); }, 3e4);
}); });
} }
@@ -167,9 +185,9 @@ class p {
data: t data: t
}); });
} }
async update(e, t, s, r) { async update(e, t, s, n) {
return this.request("update", e, { return this.request("update", e, {
schema: r?.schema, schema: n?.schema,
record_id: t, record_id: t,
data: s data: s
}); });
@@ -187,8 +205,8 @@ class p {
} }
async subscribe(e, t, s) { async subscribe(e, t, s) {
this.ensureConnected(); this.ensureConnected();
const r = l(), i = { const n = l(), i = {
id: r, id: n,
type: "subscription", type: "subscription",
operation: "subscribe", operation: "subscribe",
entity: e, entity: e,
@@ -198,7 +216,7 @@ class p {
} }
}; };
return new Promise((a, c) => { return new Promise((a, c) => {
this.messageHandlers.set(r, (o) => { this.messageHandlers.set(n, (o) => {
if (o.success && o.data?.subscription_id) { if (o.success && o.data?.subscription_id) {
const h = o.data.subscription_id; const h = o.data.subscription_id;
this.subscriptions.set(h, { this.subscriptions.set(h, {
@@ -211,7 +229,7 @@ class p {
} else } else
c(new Error(o.error?.message || "Subscription failed")); c(new Error(o.error?.message || "Subscription failed"));
}), this.send(i), setTimeout(() => { }), this.send(i), setTimeout(() => {
this.messageHandlers.has(r) && (this.messageHandlers.delete(r), c(new Error("Subscription timeout"))); this.messageHandlers.has(n) && (this.messageHandlers.delete(n), c(new Error("Subscription timeout")));
}, 1e4); }, 1e4);
}); });
} }
@@ -223,9 +241,9 @@ class p {
operation: "unsubscribe", operation: "unsubscribe",
subscription_id: e subscription_id: e
}; };
return new Promise((r, i) => { return new Promise((n, i) => {
this.messageHandlers.set(t, (a) => { this.messageHandlers.set(t, (a) => {
a.success ? (this.subscriptions.delete(e), this.log(`Unsubscribed from ${e}`), r()) : i(new Error(a.error?.message || "Unsubscribe failed")); a.success ? (this.subscriptions.delete(e), this.log(`Unsubscribed from ${e}`), n()) : i(new Error(a.error?.message || "Unsubscribe failed"));
}), this.send(s), setTimeout(() => { }), this.send(s), setTimeout(() => {
this.messageHandlers.has(t) && (this.messageHandlers.delete(t), i(new Error("Unsubscribe timeout"))); this.messageHandlers.has(t) && (this.messageHandlers.delete(t), i(new Error("Unsubscribe timeout")));
}, 1e4); }, 1e4);
@@ -309,44 +327,44 @@ class p {
this.config.debug && console.log("[WebSocketClient]", ...e); this.config.debug && console.log("[WebSocketClient]", ...e);
} }
} }
function v(n) { function W(r) {
return typeof btoa == "function" ? "ZIP_" + btoa(n) : "ZIP_" + Buffer.from(n, "utf-8").toString("base64"); return typeof btoa == "function" ? "ZIP_" + btoa(r) : "ZIP_" + Buffer.from(r, "utf-8").toString("base64");
} }
function w(n) { function H(r) {
let e = n; let e = r;
return e.startsWith("ZIP_") ? (e = e.slice(4).replace(/[\n\r ]/g, ""), e = m(e)) : e.startsWith("__") && (e = e.slice(2).replace(/[\n\r ]/g, ""), e = m(e)), (e.startsWith("ZIP_") || e.startsWith("__")) && (e = w(e)), e; return e.startsWith("ZIP_") ? (e = e.slice(4).replace(/[\n\r ]/g, ""), e = g(e)) : e.startsWith("__") && (e = e.slice(2).replace(/[\n\r ]/g, ""), e = g(e)), (e.startsWith("ZIP_") || e.startsWith("__")) && (e = H(e)), e;
} }
function m(n) { function g(r) {
return typeof atob == "function" ? atob(n) : Buffer.from(n, "base64").toString("utf-8"); return typeof atob == "function" ? atob(r) : Buffer.from(r, "base64").toString("utf-8");
} }
function u(n) { function d(r) {
const e = {}; const e = {};
if (n.columns?.length && (e["X-Select-Fields"] = n.columns.join(",")), n.omit_columns?.length && (e["X-Not-Select-Fields"] = n.omit_columns.join(",")), n.filters?.length) if (r.columns?.length && (e["X-Select-Fields"] = r.columns.join(",")), r.omit_columns?.length && (e["X-Not-Select-Fields"] = r.omit_columns.join(",")), r.filters?.length)
for (const t of n.filters) { for (const t of r.filters) {
const s = t.logic_operator ?? "AND", r = y(t.operator), i = S(t); const s = t.logic_operator ?? "AND", n = C(t.operator), i = E(t);
t.operator === "eq" && s === "AND" ? e[`X-FieldFilter-${t.column}`] = i : s === "OR" ? e[`X-SearchOr-${r}-${t.column}`] = i : e[`X-SearchOp-${r}-${t.column}`] = i; t.operator === "eq" && s === "AND" ? e[`X-FieldFilter-${t.column}`] = i : s === "OR" ? e[`X-SearchOr-${n}-${t.column}`] = i : e[`X-SearchOp-${n}-${t.column}`] = i;
} }
if (n.sort?.length) { if (r.sort?.length) {
const t = n.sort.map((s) => s.direction.toUpperCase() === "DESC" ? `-${s.column}` : `+${s.column}`); const t = r.sort.map((s) => s.direction.toUpperCase() === "DESC" ? `-${s.column}` : `+${s.column}`);
e["X-Sort"] = t.join(","); e["X-Sort"] = t.join(",");
} }
if (n.limit !== void 0 && (e["X-Limit"] = String(n.limit)), n.offset !== void 0 && (e["X-Offset"] = String(n.offset)), n.cursor_forward && (e["X-Cursor-Forward"] = n.cursor_forward), n.cursor_backward && (e["X-Cursor-Backward"] = n.cursor_backward), n.preload?.length) { if (r.limit !== void 0 && (e["X-Limit"] = String(r.limit)), r.offset !== void 0 && (e["X-Offset"] = String(r.offset)), r.cursor_forward && (e["X-Cursor-Forward"] = r.cursor_forward), r.cursor_backward && (e["X-Cursor-Backward"] = r.cursor_backward), r.preload?.length) {
const t = n.preload.map((s) => s.columns?.length ? `${s.relation}:${s.columns.join(",")}` : s.relation); const t = r.preload.map((s) => s.columns?.length ? `${s.relation}:${s.columns.join(",")}` : s.relation);
e["X-Preload"] = t.join("|"); e["X-Preload"] = t.join("|");
} }
if (n.fetch_row_number && (e["X-Fetch-RowNumber"] = n.fetch_row_number), n.computedColumns?.length) if (r.fetch_row_number && (e["X-Fetch-RowNumber"] = r.fetch_row_number), r.computedColumns?.length)
for (const t of n.computedColumns) for (const t of r.computedColumns)
e[`X-CQL-SEL-${t.name}`] = t.expression; e[`X-CQL-SEL-${t.name}`] = t.expression;
if (n.customOperators?.length) { if (r.customOperators?.length) {
const t = n.customOperators.map( const t = r.customOperators.map(
(s) => s.sql (s) => s.sql
); );
e["X-Custom-SQL-W"] = t.join(" AND "); e["X-Custom-SQL-W"] = t.join(" AND ");
} }
return e; return e;
} }
function y(n) { function C(r) {
switch (n) { switch (r) {
case "eq": case "eq":
return "equals"; return "equals";
case "neq": case "neq":
@@ -378,42 +396,39 @@ function y(n) {
case "is_not_null": case "is_not_null":
return "notempty"; return "notempty";
default: default:
return n; return r;
} }
} }
function S(n) { function E(r) {
return n.value === null || n.value === void 0 ? "" : Array.isArray(n.value) ? n.value.join(",") : String(n.value); return r.value === null || r.value === void 0 ? "" : Array.isArray(r.value) ? r.value.join(",") : String(r.value);
} }
const b = /* @__PURE__ */ new Map(); const w = /* @__PURE__ */ new Map();
function C(n) { function T(r) {
const e = n.baseUrl; const e = p(r);
let t = b.get(e); let t = w.get(e);
return t || (t = new H(n), b.set(e, t)), t; return t || (t = new _(r), w.set(e, t)), t;
} }
class H { class _ {
constructor(e) { constructor(e) {
this.config = e; this.config = { ...e, headers: { ...e.headers } };
} }
buildUrl(e, t, s) { buildUrl(e, t, s) {
let r = `${this.config.baseUrl}/${e}/${t}`; let n = `${this.config.baseUrl}/${e}/${t}`;
return s && (r += `/${s}`), r; return s && (n += `/${s}`), n;
} }
baseHeaders() { baseHeaders() {
const e = { return m(this.config);
"Content-Type": "application/json"
};
return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e;
} }
async fetchWithError(e, t) { async fetchWithError(e, t) {
const s = await fetch(e, t), r = await s.json(); const s = await fetch(e, t), n = await s.json();
if (!s.ok) if (!s.ok)
throw new Error( throw new Error(
r.error?.message || `${s.statusText} (${s.status})` n.error?.message || `${s.statusText} (${s.status})`
); );
return { return {
data: r, data: n,
success: !0, success: !0,
error: r.error ? r.error : void 0, error: n.error ? n.error : void 0,
metadata: { metadata: {
count: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0, count: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
total: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0, total: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
@@ -425,45 +440,45 @@ class H {
} }
}; };
} }
async read(e, t, s, r) { async read(e, t, s, n) {
const i = this.buildUrl(e, t, s), a = r ? u(r) : {}; const i = this.buildUrl(e, t, s), a = n ? d(n) : {};
return this.fetchWithError(i, { return this.fetchWithError(i, {
method: "GET", method: "GET",
headers: { ...this.baseHeaders(), ...a } headers: u(this.baseHeaders(), a)
}); });
} }
async create(e, t, s, r) { async create(e, t, s, n) {
const i = this.buildUrl(e, t), a = r ? u(r) : {}; const i = this.buildUrl(e, t), a = n ? d(n) : {};
return this.fetchWithError(i, { return this.fetchWithError(i, {
method: "POST", method: "POST",
headers: { ...this.baseHeaders(), ...a }, headers: u(this.baseHeaders(), a),
body: JSON.stringify(s) body: JSON.stringify(s)
}); });
} }
async update(e, t, s, r, i) { async update(e, t, s, n, i) {
const a = this.buildUrl(e, t, s), c = i ? u(i) : {}; const a = this.buildUrl(e, t, s), c = i ? d(i) : {};
return this.fetchWithError(a, { return this.fetchWithError(a, {
method: "PUT", method: "PUT",
headers: { ...this.baseHeaders(), ...c }, headers: u(this.baseHeaders(), c),
body: JSON.stringify(r) body: JSON.stringify(n)
}); });
} }
async delete(e, t, s) { async delete(e, t, s) {
const r = this.buildUrl(e, t, s); const n = this.buildUrl(e, t, s);
return this.fetchWithError(r, { return this.fetchWithError(n, {
method: "DELETE", method: "DELETE",
headers: this.baseHeaders() headers: this.baseHeaders()
}); });
} }
} }
export { export {
H as HeaderSpecClient, _ as HeaderSpecClient,
g as ResolveSpecClient, y as ResolveSpecClient,
p as WebSocketClient, S as WebSocketClient,
u as buildHeaders, d as buildHeaders,
w as decodeHeaderValue, H as decodeHeaderValue,
v as encodeHeaderValue, W as encodeHeaderValue,
C as getHeaderSpecClient, T as getHeaderSpecClient,
E as getResolveSpecClient, v as getResolveSpecClient,
_ as getWebSocketClient O as getWebSocketClient
}; };
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ResolveSpecClient, getResolveSpecClient } from '../resolvespec/client';
import { HeaderSpecClient, getHeaderSpecClient } from '../headerspec/client';
afterEach(() => vi.unstubAllGlobals());
for (const [name, Client, factory] of [
['ResolveSpec', ResolveSpecClient, getResolveSpecClient],
['HeaderSpec', HeaderSpecClient, getHeaderSpecClient],
] as const) {
describe(`${name} custom headers`, () => {
it('sends tenant headers on every operation and resolves collisions case-insensitively', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, headers: new Headers(), json: async () => ({ success: true, data: [] }),
});
vi.stubGlobal('fetch', fetchMock);
const headers = { 'X-Tenant': 'acme', authorization: 'Basic ignored', 'content-type': 'application/custom+json', 'x-limit': '99' };
const client = new Client({ baseUrl: 'http://localhost:3000', token: 'tok', headers });
await client.read('public', 'users', undefined, { limit: 10 });
await client.create('public', 'users', {});
if (client instanceof ResolveSpecClient) {
await client.update('public', 'users', {}, '1');
await client.getMetadata('public', 'users');
} else {
await client.update('public', 'users', '1', {});
}
await client.delete('public', 'users', '1');
for (const [, init] of fetchMock.mock.calls) {
const sent = new Headers(init.headers);
expect(sent.get('x-tenant')).toBe('acme');
expect(sent.get('authorization')).toBe('Bearer tok');
expect(sent.get('content-type')).toBe('application/custom+json');
}
if (client instanceof HeaderSpecClient) {
expect(new Headers(fetchMock.mock.calls[0][1].headers).get('x-limit')).toBe('10');
}
expect(headers.authorization).toBe('Basic ignored');
expect(headers['x-limit']).toBe('99');
});
it('supports custom authentication without a token', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, headers: new Headers(), json: async () => ({ success: true, data: [] }),
});
vi.stubGlobal('fetch', fetchMock);
await new Client({ baseUrl: 'http://localhost:3000', headers: { Authorization: 'Basic custom' } }).read('public', 'users');
expect(new Headers(fetchMock.mock.calls[0][1].headers).get('authorization')).toBe('Basic custom');
});
it('isolates cached clients by headers and token, and snapshots configuration', async () => {
const config = { baseUrl: 'http://tenant-cache', token: 'one', headers: { 'X-Tenant': 'acme', 'X-App': 'grid' } };
const first = factory(config);
expect(factory({ ...config, headers: { 'x-app': 'grid', 'x-tenant': 'acme' } })).toBe(first);
expect(factory({ ...config, token: 'two' })).not.toBe(first);
config.headers['X-Tenant'] = 'other';
expect(factory(config)).not.toBe(first);
const fetchMock = vi.fn().mockResolvedValue({
ok: true, headers: new Headers(), json: async () => ({ success: true, data: [] }),
});
vi.stubGlobal('fetch', fetchMock);
await first.read('public', 'users');
expect(new Headers(fetchMock.mock.calls[0][1].headers).get('x-tenant')).toBe('acme');
});
});
}
@@ -142,6 +142,7 @@ describe('HeaderSpecClient', () => {
function mockFetch<T>(data: APIResponse<T>, ok = true) { function mockFetch<T>(data: APIResponse<T>, ok = true) {
return vi.fn().mockResolvedValue({ return vi.fn().mockResolvedValue({
ok, ok,
headers: new Headers(),
json: () => Promise.resolve(data), json: () => Promise.resolve(data),
}); });
} }
+30
View File
@@ -0,0 +1,30 @@
import type { ClientConfig } from './types';
/** Merge HTTP headers case-insensitively, preserving the winning spelling. */
export function mergeHeaders(...sources: Record<string, string>[]): Record<string, string> {
const result: Record<string, string> = {};
for (const source of sources) {
for (const [name, value] of Object.entries(source)) {
for (const existing of Object.keys(result)) {
if (existing.toLowerCase() === name.toLowerCase()) delete result[existing];
}
Object.defineProperty(result, name, { value, enumerable: true, configurable: true, writable: true });
}
}
return result;
}
export function clientHeaders(config: ClientConfig): Record<string, string> {
return mergeHeaders(
{ 'Content-Type': 'application/json' },
config.headers ?? {},
config.token ? { Authorization: `Bearer ${config.token}` } : {},
);
}
export function clientCacheKey(config: ClientConfig): string {
const headers = Object.entries(clientHeaders(config))
.map(([name, value]) => [name.toLowerCase(), value])
.sort(([a], [b]) => a.localeCompare(b));
return JSON.stringify([config.baseUrl, headers]);
}
+2
View File
@@ -126,4 +126,6 @@ export interface TableMetadata {
export interface ClientConfig { export interface ClientConfig {
baseUrl: string; baseUrl: string;
token?: string; token?: string;
/** Custom HTTP headers. Token and HeaderSpec query options take precedence. */
headers?: Record<string, string>;
} }
+7 -12
View File
@@ -1,3 +1,4 @@
import { clientCacheKey, clientHeaders, mergeHeaders } from '../common/http';
import type { import type {
APIResponse, APIResponse,
ClientConfig, ClientConfig,
@@ -203,7 +204,7 @@ function formatFilterValue(filter: FilterOption): string {
const instances = new Map<string, HeaderSpecClient>(); const instances = new Map<string, HeaderSpecClient>();
export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient { export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient {
const key = config.baseUrl; const key = clientCacheKey(config);
let instance = instances.get(key); let instance = instances.get(key);
if (!instance) { if (!instance) {
instance = new HeaderSpecClient(config); instance = new HeaderSpecClient(config);
@@ -222,7 +223,7 @@ export class HeaderSpecClient {
private config: ClientConfig; private config: ClientConfig;
constructor(config: ClientConfig) { constructor(config: ClientConfig) {
this.config = config; this.config = { ...config, headers: { ...config.headers } };
} }
private buildUrl(schema: string, entity: string, id?: string): string { private buildUrl(schema: string, entity: string, id?: string): string {
@@ -234,13 +235,7 @@ export class HeaderSpecClient {
} }
private baseHeaders(): Record<string, string> { private baseHeaders(): Record<string, string> {
const headers: Record<string, string> = { return clientHeaders(this.config);
"Content-Type": "application/json",
};
if (this.config.token) {
headers["Authorization"] = `Bearer ${this.config.token}`;
}
return headers;
} }
private async fetchWithError<T>( private async fetchWithError<T>(
@@ -296,7 +291,7 @@ export class HeaderSpecClient {
const optHeaders = options ? buildHeaders(options) : {}; const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, { return this.fetchWithError<T>(url, {
method: "GET", method: "GET",
headers: { ...this.baseHeaders(), ...optHeaders }, headers: mergeHeaders(this.baseHeaders(), optHeaders),
}); });
} }
@@ -310,7 +305,7 @@ export class HeaderSpecClient {
const optHeaders = options ? buildHeaders(options) : {}; const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, { return this.fetchWithError<T>(url, {
method: "POST", method: "POST",
headers: { ...this.baseHeaders(), ...optHeaders }, headers: mergeHeaders(this.baseHeaders(), optHeaders),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
@@ -326,7 +321,7 @@ export class HeaderSpecClient {
const optHeaders = options ? buildHeaders(options) : {}; const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, { return this.fetchWithError<T>(url, {
method: "PUT", method: "PUT",
headers: { ...this.baseHeaders(), ...optHeaders }, headers: mergeHeaders(this.baseHeaders(), optHeaders),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
+4 -11
View File
@@ -1,9 +1,10 @@
import { clientCacheKey, clientHeaders } from '../common/http';
import type { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from '../common/types'; import type { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from '../common/types';
const instances = new Map<string, ResolveSpecClient>(); const instances = new Map<string, ResolveSpecClient>();
export function getResolveSpecClient(config: ClientConfig): ResolveSpecClient { export function getResolveSpecClient(config: ClientConfig): ResolveSpecClient {
const key = config.baseUrl; const key = clientCacheKey(config);
let instance = instances.get(key); let instance = instances.get(key);
if (!instance) { if (!instance) {
instance = new ResolveSpecClient(config); instance = new ResolveSpecClient(config);
@@ -16,7 +17,7 @@ export class ResolveSpecClient {
private config: ClientConfig; private config: ClientConfig;
constructor(config: ClientConfig) { constructor(config: ClientConfig) {
this.config = config; this.config = { ...config, headers: { ...config.headers } };
} }
private buildUrl(schema: string, entity: string, id?: string): string { private buildUrl(schema: string, entity: string, id?: string): string {
@@ -28,15 +29,7 @@ export class ResolveSpecClient {
} }
private baseHeaders(): HeadersInit { private baseHeaders(): HeadersInit {
const headers: Record<string, string> = { return clientHeaders(this.config);
'Content-Type': 'application/json',
};
if (this.config.token) {
headers['Authorization'] = `Bearer ${this.config.token}`;
}
return headers;
} }
private async fetchWithError<T>(url: string, options: RequestInit): Promise<APIResponse<T>> { private async fetchWithError<T>(url: string, options: RequestInit): Promise<APIResponse<T>> {