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,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) {
return vi.fn().mockResolvedValue({
ok,
headers: new Headers(),
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 {
baseUrl: 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 {
APIResponse,
ClientConfig,
@@ -203,7 +204,7 @@ function formatFilterValue(filter: FilterOption): string {
const instances = new Map<string, HeaderSpecClient>();
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<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.config.token) {
headers["Authorization"] = `Bearer ${this.config.token}`;
}
return headers;
return clientHeaders(this.config);
}
private async fetchWithError<T>(
@@ -296,7 +291,7 @@ export class HeaderSpecClient {
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(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<T>(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<T>(url, {
method: "PUT",
headers: { ...this.baseHeaders(), ...optHeaders },
headers: mergeHeaders(this.baseHeaders(), optHeaders),
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';
const instances = new Map<string, ResolveSpecClient>();
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<string, string> = {
'Content-Type': 'application/json',
};
if (this.config.token) {
headers['Authorization'] = `Bearer ${this.config.token}`;
}
return headers;
return clientHeaders(this.config);
}
private async fetchWithError<T>(url: string, options: RequestInit): Promise<APIResponse<T>> {