From b587cbd3c4919acefb0e801878c443164e7f1a03 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 23 Sep 2026 19:47:05 +0200 Subject: [PATCH] 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. --- .../.changeset/custom-http-headers.md | 5 + resolvespec-js/README.md | 24 +- resolvespec-js/dist/index.cjs | 2 +- resolvespec-js/dist/index.d.ts | 2 + resolvespec-js/dist/index.js | 235 ++++++++++-------- .../src/__tests__/custom-headers.test.ts | 65 +++++ .../src/__tests__/headerspec.test.ts | 1 + resolvespec-js/src/common/http.ts | 30 +++ resolvespec-js/src/common/types.ts | 2 + resolvespec-js/src/headerspec/client.ts | 19 +- resolvespec-js/src/resolvespec/client.ts | 15 +- 11 files changed, 265 insertions(+), 135 deletions(-) create mode 100644 resolvespec-js/.changeset/custom-http-headers.md create mode 100644 resolvespec-js/src/__tests__/custom-headers.test.ts create mode 100644 resolvespec-js/src/common/http.ts diff --git a/resolvespec-js/.changeset/custom-http-headers.md b/resolvespec-js/.changeset/custom-http-headers.md new file mode 100644 index 0000000..3c52f0e --- /dev/null +++ b/resolvespec-js/.changeset/custom-http-headers.md @@ -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. diff --git a/resolvespec-js/README.md b/resolvespec-js/README.md index c74691f..74ecffb 100644 --- a/resolvespec-js/README.md +++ b/resolvespec-js/README.md @@ -28,7 +28,7 @@ import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespe // Class instantiation 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' }); // Read with filters, sort, pagination @@ -211,3 +211,25 @@ pnpm run lint # eslint ## License 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. diff --git a/resolvespec-js/dist/index.cjs b/resolvespec-js/dist/index.cjs index 0af0eca..b8da937 100644 --- a/resolvespec-js/dist/index.cjs +++ b/resolvespec-js/dist/index.cjs @@ -1 +1 @@ -"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("uuid"),d=new Map;function w(n){const e=n.baseUrl;let t=d.get(e);return t||(t=new g(n),d.set(e,t)),t}class g{constructor(e){this.config=e}buildUrl(e,t,s){let r=`${this.config.baseUrl}/${e}/${t}`;return s&&(r+=`/${s}`),r}baseHeaders(){const e={"Content-Type":"application/json"};return this.config.token&&(e.Authorization=`Bearer ${this.config.token}`),e}async fetchWithError(e,t){const s=await fetch(e,t),r=await s.json();if(!s.ok)throw new Error(r.error?.message||"An error occurred");return r}async getMetadata(e,t){const s=this.buildUrl(e,t);return this.fetchWithError(s,{method:"GET",headers:this.baseHeaders()})}async read(e,t,s,r){const i=typeof s=="number"||typeof s=="string"?String(s):void 0,a=this.buildUrl(e,t,i),c={operation:"read",id:Array.isArray(s)?s:void 0,options:r};return this.fetchWithError(a,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(c)})}async create(e,t,s,r){const i=this.buildUrl(e,t),a={operation:"create",data:s,options:r};return this.fetchWithError(i,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(a)})}async update(e,t,s,r,i){const a=typeof r=="number"||typeof r=="string"?String(r):void 0,c=this.buildUrl(e,t,a),o={operation:"update",id:Array.isArray(r)?r:void 0,data:s,options:i};return this.fetchWithError(c,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(o)})}async delete(e,t,s){const r=this.buildUrl(e,t,String(s)),i={operation:"delete"};return this.fetchWithError(r,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(i)})}}const f=new Map;function H(n){const e=n.url;let t=f.get(e);return t||(t=new p(n),f.set(e,t)),t}class p{constructor(e){this.ws=null,this.messageHandlers=new Map,this.subscriptions=new Map,this.eventListeners={},this.state="disconnected",this.reconnectAttempts=0,this.reconnectTimer=null,this.heartbeatTimer=null,this.isManualClose=!1,this.config={url:e.url,reconnect:e.reconnect??!0,reconnectInterval:e.reconnectInterval??3e3,maxReconnectAttempts:e.maxReconnectAttempts??10,heartbeatInterval:e.heartbeatInterval??3e4,debug:e.debug??!1}}async connect(){if(this.ws?.readyState===WebSocket.OPEN){this.log("Already connected");return}return this.isManualClose=!1,this.setState("connecting"),new Promise((e,t)=>{try{this.ws=new WebSocket(this.config.url),this.ws.onopen=()=>{this.log("Connected to WebSocket server"),this.setState("connected"),this.reconnectAttempts=0,this.startHeartbeat(),this.emit("connect"),e()},this.ws.onmessage=s=>{this.handleMessage(s.data)},this.ws.onerror=s=>{this.log("WebSocket error:",s);const r=new Error("WebSocket connection error");this.emit("error",r),t(r)},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.connect().catch(r=>{this.log("Reconnection failed:",r)})},this.config.reconnectInterval))}}catch(s){t(s)}})}disconnect(){this.isManualClose=!0,this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.stopHeartbeat(),this.ws&&(this.setState("disconnecting"),this.ws.close(),this.ws=null),this.setState("disconnected"),this.messageHandlers.clear()}async request(e,t,s){this.ensureConnected();const r=l.v4(),i={id:r,type:"request",operation:e,entity:t,schema:s?.schema,record_id:s?.record_id,data:s?.data,options:s?.options};return new Promise((a,c)=>{this.messageHandlers.set(r,o=>{o.success?a(o.data):c(new Error(o.error?.message||"Request failed"))}),this.send(i),setTimeout(()=>{this.messageHandlers.has(r)&&(this.messageHandlers.delete(r),c(new Error("Request timeout")))},3e4)})}async read(e,t){return this.request("read",e,{schema:t?.schema,record_id:t?.record_id,options:{filters:t?.filters,columns:t?.columns,sort:t?.sort,preload:t?.preload,limit:t?.limit,offset:t?.offset}})}async create(e,t,s){return this.request("create",e,{schema:s?.schema,data:t})}async update(e,t,s,r){return this.request("update",e,{schema:r?.schema,record_id:t,data:s})}async delete(e,t,s){await this.request("delete",e,{schema:s?.schema,record_id:t})}async meta(e,t){return this.request("meta",e,{schema:t?.schema})}async subscribe(e,t,s){this.ensureConnected();const r=l.v4(),i={id:r,type:"subscription",operation:"subscribe",entity:e,schema:s?.schema,options:{filters:s?.filters}};return new Promise((a,c)=>{this.messageHandlers.set(r,o=>{if(o.success&&o.data?.subscription_id){const h=o.data.subscription_id;this.subscriptions.set(h,{id:h,entity:e,schema:s?.schema,options:{filters:s?.filters},callback:t}),this.log(`Subscribed to ${e} with ID: ${h}`),a(h)}else c(new Error(o.error?.message||"Subscription failed"))}),this.send(i),setTimeout(()=>{this.messageHandlers.has(r)&&(this.messageHandlers.delete(r),c(new Error("Subscription timeout")))},1e4)})}async unsubscribe(e){this.ensureConnected();const t=l.v4(),s={id:t,type:"subscription",operation:"unsubscribe",subscription_id:e};return new Promise((r,i)=>{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"))}),this.send(s),setTimeout(()=>{this.messageHandlers.has(t)&&(this.messageHandlers.delete(t),i(new Error("Unsubscribe timeout")))},1e4)})}getSubscriptions(){return Array.from(this.subscriptions.values())}getState(){return this.state}isConnected(){return this.ws?.readyState===WebSocket.OPEN}on(e,t){this.eventListeners[e]=t}off(e){delete this.eventListeners[e]}handleMessage(e){try{const t=JSON.parse(e);switch(this.log("Received message:",t),this.emit("message",t),t.type){case"response":this.handleResponse(t);break;case"notification":this.handleNotification(t);break;case"pong":break;default:this.log("Unknown message type:",t.type)}}catch(t){this.log("Error parsing message:",t)}}handleResponse(e){const t=this.messageHandlers.get(e.id);t&&(t(e),this.messageHandlers.delete(e.id))}handleNotification(e){const t=this.subscriptions.get(e.subscription_id);t?.callback&&t.callback(e)}send(e){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw new Error("WebSocket is not connected");const t=JSON.stringify(e);this.log("Sending message:",e),this.ws.send(t)}startHeartbeat(){this.heartbeatTimer||(this.heartbeatTimer=setInterval(()=>{if(this.isConnected()){const e={id:l.v4(),type:"ping"};this.send(e)}},this.config.heartbeatInterval))}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}setState(e){this.state!==e&&(this.state=e,this.emit("stateChange",e))}ensureConnected(){if(!this.isConnected())throw new Error("WebSocket is not connected. Call connect() first.")}emit(e,...t){const s=this.eventListeners[e];s&&s(...t)}log(...e){this.config.debug&&console.log("[WebSocketClient]",...e)}}function v(n){return typeof btoa=="function"?"ZIP_"+btoa(n):"ZIP_"+Buffer.from(n,"utf-8").toString("base64")}function S(n){let e=n;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=S(e)),e}function m(n){return typeof atob=="function"?atob(n):Buffer.from(n,"base64").toString("utf-8")}function u(n){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)for(const t of n.filters){const s=t.logic_operator??"AND",r=C(t.operator),i=k(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}if(n.sort?.length){const t=n.sort.map(s=>s.direction.toUpperCase()==="DESC"?`-${s.column}`:`+${s.column}`);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){const t=n.preload.map(s=>s.columns?.length?`${s.relation}:${s.columns.join(",")}`:s.relation);e["X-Preload"]=t.join("|")}if(n.fetch_row_number&&(e["X-Fetch-RowNumber"]=n.fetch_row_number),n.computedColumns?.length)for(const t of n.computedColumns)e[`X-CQL-SEL-${t.name}`]=t.expression;if(n.customOperators?.length){const t=n.customOperators.map(s=>s.sql);e["X-Custom-SQL-W"]=t.join(" AND ")}return e}function C(n){switch(n){case"eq":return"equals";case"neq":return"notequals";case"gt":return"greaterthan";case"gte":return"greaterthanorequal";case"lt":return"lessthan";case"lte":return"lessthanorequal";case"like":case"ilike":case"contains":return"contains";case"startswith":return"beginswith";case"endswith":return"endswith";case"in":return"in";case"between":return"between";case"between_inclusive":return"betweeninclusive";case"is_null":return"empty";case"is_not_null":return"notempty";default:return n}}function k(n){return n.value===null||n.value===void 0?"":Array.isArray(n.value)?n.value.join(","):String(n.value)}const b=new Map;function E(n){const e=n.baseUrl;let t=b.get(e);return t||(t=new y(n),b.set(e,t)),t}class y{constructor(e){this.config=e}buildUrl(e,t,s){let r=`${this.config.baseUrl}/${e}/${t}`;return s&&(r+=`/${s}`),r}baseHeaders(){const e={"Content-Type":"application/json"};return this.config.token&&(e.Authorization=`Bearer ${this.config.token}`),e}async fetchWithError(e,t){const s=await fetch(e,t),r=await s.json();if(!s.ok)throw new Error(r.error?.message||`${s.statusText} (${s.status})`);return{data:r,success:!0,error:r.error?r.error:void 0,metadata:{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,filtered:s.headers.get("content-range")?Number(s.headers.get("content-range")?.split("/")[1]):0,offset:s.headers.get("content-range")?Number(s.headers.get("content-range")?.split("/")[0].split("-")[0]):0,limit:s.headers.get("x-limit")?Number(s.headers.get("x-limit")):0}}}async read(e,t,s,r){const i=this.buildUrl(e,t,s),a=r?u(r):{};return this.fetchWithError(i,{method:"GET",headers:{...this.baseHeaders(),...a}})}async create(e,t,s,r){const i=this.buildUrl(e,t),a=r?u(r):{};return this.fetchWithError(i,{method:"POST",headers:{...this.baseHeaders(),...a},body:JSON.stringify(s)})}async update(e,t,s,r,i){const a=this.buildUrl(e,t,s),c=i?u(i):{};return this.fetchWithError(a,{method:"PUT",headers:{...this.baseHeaders(),...c},body:JSON.stringify(r)})}async delete(e,t,s){const r=this.buildUrl(e,t,s);return this.fetchWithError(r,{method:"DELETE",headers:this.baseHeaders()})}}exports.HeaderSpecClient=y;exports.ResolveSpecClient=g;exports.WebSocketClient=p;exports.buildHeaders=u;exports.decodeHeaderValue=S;exports.encodeHeaderValue=v;exports.getHeaderSpecClient=E;exports.getResolveSpecClient=w;exports.getWebSocketClient=H; +"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const h=require("uuid");function u(...r){const e={};for(const t of r)for(const[s,n]of Object.entries(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}function f(r){return u({"Content-Type":"application/json"},r.headers??{},r.token?{Authorization:`Bearer ${r.token}`}:{})}function y(r){const e=Object.entries(f(r)).map(([t,s])=>[t.toLowerCase(),s]).sort(([t],[s])=>t.localeCompare(s));return JSON.stringify([r.baseUrl,e])}const m=new Map;function v(r){const e=y(r);let t=m.get(e);return t||(t=new w(r),m.set(e,t)),t}class w{constructor(e){this.config={...e,headers:{...e.headers}}}buildUrl(e,t,s){let n=`${this.config.baseUrl}/${e}/${t}`;return s&&(n+=`/${s}`),n}baseHeaders(){return f(this.config)}async fetchWithError(e,t){const s=await fetch(e,t),n=await s.json();if(!s.ok)throw new Error(n.error?.message||"An error occurred");return n}async getMetadata(e,t){const s=this.buildUrl(e,t);return this.fetchWithError(s,{method:"GET",headers:this.baseHeaders()})}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={operation:"read",id:Array.isArray(s)?s:void 0,options:n};return this.fetchWithError(a,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(c)})}async create(e,t,s,n){const i=this.buildUrl(e,t),a={operation:"create",data:s,options:n};return this.fetchWithError(i,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(a)})}async update(e,t,s,n,i){const a=typeof n=="number"||typeof n=="string"?String(n):void 0,c=this.buildUrl(e,t,a),o={operation:"update",id:Array.isArray(n)?n:void 0,data:s,options:i};return this.fetchWithError(c,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(o)})}async delete(e,t,s){const n=this.buildUrl(e,t,String(s)),i={operation:"delete"};return this.fetchWithError(n,{method:"POST",headers:this.baseHeaders(),body:JSON.stringify(i)})}}const b=new Map;function k(r){const e=r.url;let t=b.get(e);return t||(t=new S(r),b.set(e,t)),t}class S{constructor(e){this.ws=null,this.messageHandlers=new Map,this.subscriptions=new Map,this.eventListeners={},this.state="disconnected",this.reconnectAttempts=0,this.reconnectTimer=null,this.heartbeatTimer=null,this.isManualClose=!1,this.config={url:e.url,reconnect:e.reconnect??!0,reconnectInterval:e.reconnectInterval??3e3,maxReconnectAttempts:e.maxReconnectAttempts??10,heartbeatInterval:e.heartbeatInterval??3e4,debug:e.debug??!1}}async connect(){if(this.ws?.readyState===WebSocket.OPEN){this.log("Already connected");return}return this.isManualClose=!1,this.setState("connecting"),new Promise((e,t)=>{try{this.ws=new WebSocket(this.config.url),this.ws.onopen=()=>{this.log("Connected to WebSocket server"),this.setState("connected"),this.reconnectAttempts=0,this.startHeartbeat(),this.emit("connect"),e()},this.ws.onmessage=s=>{this.handleMessage(s.data)},this.ws.onerror=s=>{this.log("WebSocket error:",s);const n=new Error("WebSocket connection error");this.emit("error",n),t(n)},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.connect().catch(n=>{this.log("Reconnection failed:",n)})},this.config.reconnectInterval))}}catch(s){t(s)}})}disconnect(){this.isManualClose=!0,this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.stopHeartbeat(),this.ws&&(this.setState("disconnecting"),this.ws.close(),this.ws=null),this.setState("disconnected"),this.messageHandlers.clear()}async request(e,t,s){this.ensureConnected();const n=h.v4(),i={id:n,type:"request",operation:e,entity:t,schema:s?.schema,record_id:s?.record_id,data:s?.data,options:s?.options};return new Promise((a,c)=>{this.messageHandlers.set(n,o=>{o.success?a(o.data):c(new Error(o.error?.message||"Request failed"))}),this.send(i),setTimeout(()=>{this.messageHandlers.has(n)&&(this.messageHandlers.delete(n),c(new Error("Request timeout")))},3e4)})}async read(e,t){return this.request("read",e,{schema:t?.schema,record_id:t?.record_id,options:{filters:t?.filters,columns:t?.columns,sort:t?.sort,preload:t?.preload,limit:t?.limit,offset:t?.offset}})}async create(e,t,s){return this.request("create",e,{schema:s?.schema,data:t})}async update(e,t,s,n){return this.request("update",e,{schema:n?.schema,record_id:t,data:s})}async delete(e,t,s){await this.request("delete",e,{schema:s?.schema,record_id:t})}async meta(e,t){return this.request("meta",e,{schema:t?.schema})}async subscribe(e,t,s){this.ensureConnected();const n=h.v4(),i={id:n,type:"subscription",operation:"subscribe",entity:e,schema:s?.schema,options:{filters:s?.filters}};return new Promise((a,c)=>{this.messageHandlers.set(n,o=>{if(o.success&&o.data?.subscription_id){const l=o.data.subscription_id;this.subscriptions.set(l,{id:l,entity:e,schema:s?.schema,options:{filters:s?.filters},callback:t}),this.log(`Subscribed to ${e} with ID: ${l}`),a(l)}else c(new Error(o.error?.message||"Subscription failed"))}),this.send(i),setTimeout(()=>{this.messageHandlers.has(n)&&(this.messageHandlers.delete(n),c(new Error("Subscription timeout")))},1e4)})}async unsubscribe(e){this.ensureConnected();const t=h.v4(),s={id:t,type:"subscription",operation:"unsubscribe",subscription_id:e};return new Promise((n,i)=>{this.messageHandlers.set(t,a=>{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.messageHandlers.has(t)&&(this.messageHandlers.delete(t),i(new Error("Unsubscribe timeout")))},1e4)})}getSubscriptions(){return Array.from(this.subscriptions.values())}getState(){return this.state}isConnected(){return this.ws?.readyState===WebSocket.OPEN}on(e,t){this.eventListeners[e]=t}off(e){delete this.eventListeners[e]}handleMessage(e){try{const t=JSON.parse(e);switch(this.log("Received message:",t),this.emit("message",t),t.type){case"response":this.handleResponse(t);break;case"notification":this.handleNotification(t);break;case"pong":break;default:this.log("Unknown message type:",t.type)}}catch(t){this.log("Error parsing message:",t)}}handleResponse(e){const t=this.messageHandlers.get(e.id);t&&(t(e),this.messageHandlers.delete(e.id))}handleNotification(e){const t=this.subscriptions.get(e.subscription_id);t?.callback&&t.callback(e)}send(e){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw new Error("WebSocket is not connected");const t=JSON.stringify(e);this.log("Sending message:",e),this.ws.send(t)}startHeartbeat(){this.heartbeatTimer||(this.heartbeatTimer=setInterval(()=>{if(this.isConnected()){const e={id:h.v4(),type:"ping"};this.send(e)}},this.config.heartbeatInterval))}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}setState(e){this.state!==e&&(this.state=e,this.emit("stateChange",e))}ensureConnected(){if(!this.isConnected())throw new Error("WebSocket is not connected. Call connect() first.")}emit(e,...t){const s=this.eventListeners[e];s&&s(...t)}log(...e){this.config.debug&&console.log("[WebSocketClient]",...e)}}function E(r){return typeof btoa=="function"?"ZIP_"+btoa(r):"ZIP_"+Buffer.from(r,"utf-8").toString("base64")}function H(r){let e=r;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 g(r){return typeof atob=="function"?atob(r):Buffer.from(r,"base64").toString("utf-8")}function d(r){const e={};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 r.filters){const s=t.logic_operator??"AND",n=_(t.operator),i=O(t);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(r.sort?.length){const t=r.sort.map(s=>s.direction.toUpperCase()==="DESC"?`-${s.column}`:`+${s.column}`);e["X-Sort"]=t.join(",")}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=r.preload.map(s=>s.columns?.length?`${s.relation}:${s.columns.join(",")}`:s.relation);e["X-Preload"]=t.join("|")}if(r.fetch_row_number&&(e["X-Fetch-RowNumber"]=r.fetch_row_number),r.computedColumns?.length)for(const t of r.computedColumns)e[`X-CQL-SEL-${t.name}`]=t.expression;if(r.customOperators?.length){const t=r.customOperators.map(s=>s.sql);e["X-Custom-SQL-W"]=t.join(" AND ")}return e}function _(r){switch(r){case"eq":return"equals";case"neq":return"notequals";case"gt":return"greaterthan";case"gte":return"greaterthanorequal";case"lt":return"lessthan";case"lte":return"lessthanorequal";case"like":case"ilike":case"contains":return"contains";case"startswith":return"beginswith";case"endswith":return"endswith";case"in":return"in";case"between":return"between";case"between_inclusive":return"betweeninclusive";case"is_null":return"empty";case"is_not_null":return"notempty";default:return r}}function O(r){return r.value===null||r.value===void 0?"":Array.isArray(r.value)?r.value.join(","):String(r.value)}const p=new Map;function W(r){const e=y(r);let t=p.get(e);return t||(t=new C(r),p.set(e,t)),t}class C{constructor(e){this.config={...e,headers:{...e.headers}}}buildUrl(e,t,s){let n=`${this.config.baseUrl}/${e}/${t}`;return s&&(n+=`/${s}`),n}baseHeaders(){return f(this.config)}async fetchWithError(e,t){const s=await fetch(e,t),n=await s.json();if(!s.ok)throw new Error(n.error?.message||`${s.statusText} (${s.status})`);return{data:n,success:!0,error:n.error?n.error:void 0,metadata:{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,filtered:s.headers.get("content-range")?Number(s.headers.get("content-range")?.split("/")[1]):0,offset:s.headers.get("content-range")?Number(s.headers.get("content-range")?.split("/")[0].split("-")[0]):0,limit:s.headers.get("x-limit")?Number(s.headers.get("x-limit")):0}}}async read(e,t,s,n){const i=this.buildUrl(e,t,s),a=n?d(n):{};return this.fetchWithError(i,{method:"GET",headers:u(this.baseHeaders(),a)})}async create(e,t,s,n){const i=this.buildUrl(e,t),a=n?d(n):{};return this.fetchWithError(i,{method:"POST",headers:u(this.baseHeaders(),a),body:JSON.stringify(s)})}async update(e,t,s,n,i){const a=this.buildUrl(e,t,s),c=i?d(i):{};return this.fetchWithError(a,{method:"PUT",headers:u(this.baseHeaders(),c),body:JSON.stringify(n)})}async delete(e,t,s){const n=this.buildUrl(e,t,s);return this.fetchWithError(n,{method:"DELETE",headers:this.baseHeaders()})}}exports.HeaderSpecClient=C;exports.ResolveSpecClient=w;exports.WebSocketClient=S;exports.buildHeaders=d;exports.decodeHeaderValue=H;exports.encodeHeaderValue=E;exports.getHeaderSpecClient=W;exports.getResolveSpecClient=v;exports.getWebSocketClient=k; diff --git a/resolvespec-js/dist/index.d.ts b/resolvespec-js/dist/index.d.ts index 52dc330..b782dc8 100644 --- a/resolvespec-js/dist/index.d.ts +++ b/resolvespec-js/dist/index.d.ts @@ -34,6 +34,8 @@ export declare function buildHeaders(options: Options): Record; export declare interface ClientConfig { baseUrl: string; token?: string; + /** Custom HTTP headers. Token and HeaderSpec query options take precedence. */ + headers?: Record; } export declare interface Column { diff --git a/resolvespec-js/dist/index.js b/resolvespec-js/dist/index.js index 0fa4ec6..cb40d0f 100644 --- a/resolvespec-js/dist/index.js +++ b/resolvespec-js/dist/index.js @@ -1,29 +1,47 @@ import { v4 as l } from "uuid"; -const d = /* @__PURE__ */ new Map(); -function E(n) { - const e = n.baseUrl; - let t = d.get(e); - return t || (t = new g(n), d.set(e, t)), t; +function u(...r) { + const e = {}; + for (const t of r) + for (const [s, n] of Object.entries(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) { - this.config = e; + this.config = { ...e, headers: { ...e.headers } }; } buildUrl(e, t, s) { - let r = `${this.config.baseUrl}/${e}/${t}`; - return s && (r += `/${s}`), r; + let n = `${this.config.baseUrl}/${e}/${t}`; + return s && (n += `/${s}`), n; } baseHeaders() { - const e = { - "Content-Type": "application/json" - }; - return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e; + return m(this.config); } 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) - throw new Error(r.error?.message || "An error occurred"); - return r; + throw new Error(n.error?.message || "An error occurred"); + return n; } async getMetadata(e, t) { const s = this.buildUrl(e, t); @@ -32,11 +50,11 @@ class g { 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 = { operation: "read", id: Array.isArray(s) ? s : void 0, - options: r + options: n }; return this.fetchWithError(a, { method: "POST", @@ -44,11 +62,11 @@ class g { body: JSON.stringify(c) }); } - async create(e, t, s, r) { + async create(e, t, s, n) { const i = this.buildUrl(e, t), a = { operation: "create", data: s, - options: r + options: n }; return this.fetchWithError(i, { method: "POST", @@ -56,10 +74,10 @@ class g { body: JSON.stringify(a) }); } - async update(e, t, s, r, i) { - const a = typeof r == "number" || typeof r == "string" ? String(r) : void 0, c = this.buildUrl(e, t, a), o = { + async update(e, t, s, n, i) { + const a = typeof n == "number" || typeof n == "string" ? String(n) : void 0, c = this.buildUrl(e, t, a), o = { operation: "update", - id: Array.isArray(r) ? r : void 0, + id: Array.isArray(n) ? n : void 0, data: s, options: i }; @@ -70,23 +88,23 @@ class g { }); } 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" }; - return this.fetchWithError(r, { + return this.fetchWithError(n, { method: "POST", headers: this.baseHeaders(), body: JSON.stringify(i) }); } } -const f = /* @__PURE__ */ new Map(); -function _(n) { - const e = n.url; - let t = f.get(e); - return t || (t = new p(n), f.set(e, t)), t; +const b = /* @__PURE__ */ new Map(); +function O(r) { + const e = r.url; + let t = b.get(e); + return t || (t = new S(r), b.set(e, t)), t; } -class p { +class S { 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 = { url: e.url, @@ -110,12 +128,12 @@ class p { this.handleMessage(s.data); }, this.ws.onerror = (s) => { this.log("WebSocket error:", s); - const r = new Error("WebSocket connection error"); - this.emit("error", r), t(r); + const n = new Error("WebSocket connection error"); + this.emit("error", n), t(n); }, 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.connect().catch((r) => { - this.log("Reconnection failed:", r); + this.connect().catch((n) => { + this.log("Reconnection failed:", n); }); }, this.config.reconnectInterval)); }; @@ -129,8 +147,8 @@ class p { } async request(e, t, s) { this.ensureConnected(); - const r = l(), i = { - id: r, + const n = l(), i = { + id: n, type: "request", operation: e, entity: t, @@ -140,10 +158,10 @@ class p { options: s?.options }; 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")); }), 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); }); } @@ -167,9 +185,9 @@ class p { data: t }); } - async update(e, t, s, r) { + async update(e, t, s, n) { return this.request("update", e, { - schema: r?.schema, + schema: n?.schema, record_id: t, data: s }); @@ -187,8 +205,8 @@ class p { } async subscribe(e, t, s) { this.ensureConnected(); - const r = l(), i = { - id: r, + const n = l(), i = { + id: n, type: "subscription", operation: "subscribe", entity: e, @@ -198,7 +216,7 @@ class p { } }; return new Promise((a, c) => { - this.messageHandlers.set(r, (o) => { + this.messageHandlers.set(n, (o) => { if (o.success && o.data?.subscription_id) { const h = o.data.subscription_id; this.subscriptions.set(h, { @@ -211,7 +229,7 @@ class p { } else c(new Error(o.error?.message || "Subscription failed")); }), 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); }); } @@ -223,9 +241,9 @@ class p { operation: "unsubscribe", subscription_id: e }; - return new Promise((r, i) => { + return new Promise((n, i) => { 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.messageHandlers.has(t) && (this.messageHandlers.delete(t), i(new Error("Unsubscribe timeout"))); }, 1e4); @@ -309,44 +327,44 @@ class p { this.config.debug && console.log("[WebSocketClient]", ...e); } } -function v(n) { - return typeof btoa == "function" ? "ZIP_" + btoa(n) : "ZIP_" + Buffer.from(n, "utf-8").toString("base64"); +function W(r) { + return typeof btoa == "function" ? "ZIP_" + btoa(r) : "ZIP_" + Buffer.from(r, "utf-8").toString("base64"); } -function w(n) { - let e = n; - 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; +function H(r) { + let e = r; + 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) { - return typeof atob == "function" ? atob(n) : Buffer.from(n, "base64").toString("utf-8"); +function g(r) { + return typeof atob == "function" ? atob(r) : Buffer.from(r, "base64").toString("utf-8"); } -function u(n) { +function d(r) { 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) - for (const t of n.filters) { - const s = t.logic_operator ?? "AND", r = y(t.operator), i = S(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; + 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 r.filters) { + 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-${n}-${t.column}`] = i : e[`X-SearchOp-${n}-${t.column}`] = i; } - if (n.sort?.length) { - const t = n.sort.map((s) => s.direction.toUpperCase() === "DESC" ? `-${s.column}` : `+${s.column}`); + if (r.sort?.length) { + const t = r.sort.map((s) => s.direction.toUpperCase() === "DESC" ? `-${s.column}` : `+${s.column}`); 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) { - const t = n.preload.map((s) => s.columns?.length ? `${s.relation}:${s.columns.join(",")}` : s.relation); + 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 = r.preload.map((s) => s.columns?.length ? `${s.relation}:${s.columns.join(",")}` : s.relation); e["X-Preload"] = t.join("|"); } - if (n.fetch_row_number && (e["X-Fetch-RowNumber"] = n.fetch_row_number), n.computedColumns?.length) - for (const t of n.computedColumns) + if (r.fetch_row_number && (e["X-Fetch-RowNumber"] = r.fetch_row_number), r.computedColumns?.length) + for (const t of r.computedColumns) e[`X-CQL-SEL-${t.name}`] = t.expression; - if (n.customOperators?.length) { - const t = n.customOperators.map( + if (r.customOperators?.length) { + const t = r.customOperators.map( (s) => s.sql ); e["X-Custom-SQL-W"] = t.join(" AND "); } return e; } -function y(n) { - switch (n) { +function C(r) { + switch (r) { case "eq": return "equals"; case "neq": @@ -378,42 +396,39 @@ function y(n) { case "is_not_null": return "notempty"; default: - return n; + return r; } } -function S(n) { - return n.value === null || n.value === void 0 ? "" : Array.isArray(n.value) ? n.value.join(",") : String(n.value); +function E(r) { + return r.value === null || r.value === void 0 ? "" : Array.isArray(r.value) ? r.value.join(",") : String(r.value); } -const b = /* @__PURE__ */ new Map(); -function C(n) { - const e = n.baseUrl; - let t = b.get(e); - return t || (t = new H(n), b.set(e, t)), t; +const w = /* @__PURE__ */ new Map(); +function T(r) { + const e = p(r); + let t = w.get(e); + return t || (t = new _(r), w.set(e, t)), t; } -class H { +class _ { constructor(e) { - this.config = e; + this.config = { ...e, headers: { ...e.headers } }; } buildUrl(e, t, s) { - let r = `${this.config.baseUrl}/${e}/${t}`; - return s && (r += `/${s}`), r; + let n = `${this.config.baseUrl}/${e}/${t}`; + return s && (n += `/${s}`), n; } baseHeaders() { - const e = { - "Content-Type": "application/json" - }; - return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e; + return m(this.config); } 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) throw new Error( - r.error?.message || `${s.statusText} (${s.status})` + n.error?.message || `${s.statusText} (${s.status})` ); return { - data: r, + data: n, success: !0, - error: r.error ? r.error : void 0, + error: n.error ? n.error : void 0, metadata: { 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, @@ -425,45 +440,45 @@ class H { } }; } - async read(e, t, s, r) { - const i = this.buildUrl(e, t, s), a = r ? u(r) : {}; + async read(e, t, s, n) { + const i = this.buildUrl(e, t, s), a = n ? d(n) : {}; return this.fetchWithError(i, { method: "GET", - headers: { ...this.baseHeaders(), ...a } + headers: u(this.baseHeaders(), a) }); } - async create(e, t, s, r) { - const i = this.buildUrl(e, t), a = r ? u(r) : {}; + async create(e, t, s, n) { + const i = this.buildUrl(e, t), a = n ? d(n) : {}; return this.fetchWithError(i, { method: "POST", - headers: { ...this.baseHeaders(), ...a }, + headers: u(this.baseHeaders(), a), body: JSON.stringify(s) }); } - async update(e, t, s, r, i) { - const a = this.buildUrl(e, t, s), c = i ? u(i) : {}; + async update(e, t, s, n, i) { + const a = this.buildUrl(e, t, s), c = i ? d(i) : {}; return this.fetchWithError(a, { method: "PUT", - headers: { ...this.baseHeaders(), ...c }, - body: JSON.stringify(r) + headers: u(this.baseHeaders(), c), + body: JSON.stringify(n) }); } async delete(e, t, s) { - const r = this.buildUrl(e, t, s); - return this.fetchWithError(r, { + const n = this.buildUrl(e, t, s); + return this.fetchWithError(n, { method: "DELETE", headers: this.baseHeaders() }); } } export { - H as HeaderSpecClient, - g as ResolveSpecClient, - p as WebSocketClient, - u as buildHeaders, - w as decodeHeaderValue, - v as encodeHeaderValue, - C as getHeaderSpecClient, - E as getResolveSpecClient, - _ as getWebSocketClient + _ as HeaderSpecClient, + y as ResolveSpecClient, + S as WebSocketClient, + d as buildHeaders, + H as decodeHeaderValue, + W as encodeHeaderValue, + T as getHeaderSpecClient, + v as getResolveSpecClient, + O as getWebSocketClient }; diff --git a/resolvespec-js/src/__tests__/custom-headers.test.ts b/resolvespec-js/src/__tests__/custom-headers.test.ts new file mode 100644 index 0000000..ce7eccf --- /dev/null +++ b/resolvespec-js/src/__tests__/custom-headers.test.ts @@ -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'); + }); + }); +} diff --git a/resolvespec-js/src/__tests__/headerspec.test.ts b/resolvespec-js/src/__tests__/headerspec.test.ts index 54ac730..143c5c0 100644 --- a/resolvespec-js/src/__tests__/headerspec.test.ts +++ b/resolvespec-js/src/__tests__/headerspec.test.ts @@ -142,6 +142,7 @@ describe('HeaderSpecClient', () => { function mockFetch(data: APIResponse, ok = true) { return vi.fn().mockResolvedValue({ ok, + headers: new Headers(), json: () => Promise.resolve(data), }); } diff --git a/resolvespec-js/src/common/http.ts b/resolvespec-js/src/common/http.ts new file mode 100644 index 0000000..d4930dd --- /dev/null +++ b/resolvespec-js/src/common/http.ts @@ -0,0 +1,30 @@ +import type { ClientConfig } from './types'; + +/** Merge HTTP headers case-insensitively, preserving the winning spelling. */ +export function mergeHeaders(...sources: Record[]): Record { + const result: Record = {}; + 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 { + 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]); +} diff --git a/resolvespec-js/src/common/types.ts b/resolvespec-js/src/common/types.ts index 83628c7..fd148d0 100644 --- a/resolvespec-js/src/common/types.ts +++ b/resolvespec-js/src/common/types.ts @@ -126,4 +126,6 @@ export interface TableMetadata { export interface ClientConfig { baseUrl: string; token?: string; + /** Custom HTTP headers. Token and HeaderSpec query options take precedence. */ + headers?: Record; } diff --git a/resolvespec-js/src/headerspec/client.ts b/resolvespec-js/src/headerspec/client.ts index 15e74a5..b88f108 100644 --- a/resolvespec-js/src/headerspec/client.ts +++ b/resolvespec-js/src/headerspec/client.ts @@ -1,3 +1,4 @@ +import { clientCacheKey, clientHeaders, mergeHeaders } from '../common/http'; import type { APIResponse, ClientConfig, @@ -203,7 +204,7 @@ function formatFilterValue(filter: FilterOption): string { const instances = new Map(); export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient { - const key = config.baseUrl; + const key = clientCacheKey(config); let instance = instances.get(key); if (!instance) { instance = new HeaderSpecClient(config); @@ -222,7 +223,7 @@ export class HeaderSpecClient { private config: ClientConfig; constructor(config: ClientConfig) { - this.config = config; + this.config = { ...config, headers: { ...config.headers } }; } private buildUrl(schema: string, entity: string, id?: string): string { @@ -234,13 +235,7 @@ export class HeaderSpecClient { } private baseHeaders(): Record { - const headers: Record = { - "Content-Type": "application/json", - }; - if (this.config.token) { - headers["Authorization"] = `Bearer ${this.config.token}`; - } - return headers; + return clientHeaders(this.config); } private async fetchWithError( @@ -296,7 +291,7 @@ export class HeaderSpecClient { const optHeaders = options ? buildHeaders(options) : {}; return this.fetchWithError(url, { method: "GET", - headers: { ...this.baseHeaders(), ...optHeaders }, + headers: mergeHeaders(this.baseHeaders(), optHeaders), }); } @@ -310,7 +305,7 @@ export class HeaderSpecClient { const optHeaders = options ? buildHeaders(options) : {}; return this.fetchWithError(url, { method: "POST", - headers: { ...this.baseHeaders(), ...optHeaders }, + headers: mergeHeaders(this.baseHeaders(), optHeaders), body: JSON.stringify(data), }); } @@ -326,7 +321,7 @@ export class HeaderSpecClient { const optHeaders = options ? buildHeaders(options) : {}; return this.fetchWithError(url, { method: "PUT", - headers: { ...this.baseHeaders(), ...optHeaders }, + headers: mergeHeaders(this.baseHeaders(), optHeaders), body: JSON.stringify(data), }); } diff --git a/resolvespec-js/src/resolvespec/client.ts b/resolvespec-js/src/resolvespec/client.ts index 4dcbe5c..b5af6f8 100644 --- a/resolvespec-js/src/resolvespec/client.ts +++ b/resolvespec-js/src/resolvespec/client.ts @@ -1,9 +1,10 @@ +import { clientCacheKey, clientHeaders } from '../common/http'; import type { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from '../common/types'; const instances = new Map(); export function getResolveSpecClient(config: ClientConfig): ResolveSpecClient { - const key = config.baseUrl; + const key = clientCacheKey(config); let instance = instances.get(key); if (!instance) { instance = new ResolveSpecClient(config); @@ -16,7 +17,7 @@ export class ResolveSpecClient { private config: ClientConfig; constructor(config: ClientConfig) { - this.config = config; + this.config = { ...config, headers: { ...config.headers } }; } private buildUrl(schema: string, entity: string, id?: string): string { @@ -28,15 +29,7 @@ export class ResolveSpecClient { } private baseHeaders(): HeadersInit { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (this.config.token) { - headers['Authorization'] = `Bearer ${this.config.token}`; - } - - return headers; + return clientHeaders(this.config); } private async fetchWithError(url: string, options: RequestInit): Promise> {