mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
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:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user