refactor(headerspec): improve code formatting and consistency

This commit is contained in:
2026-02-15 19:49:29 +02:00
parent dc85008d7f
commit b76dae5991

View File

@@ -1,50 +1,50 @@
import type { import type {
ClientConfig, APIResponse,
APIResponse, ClientConfig,
Options, CustomOperator,
FilterOption, FilterOption,
SortOption, Options,
PreloadOption, PreloadOption,
CustomOperator, SortOption,
} from '../common/types'; } from "../common/types";
/** /**
* Encode a value with base64 and ZIP_ prefix for complex header values. * Encode a value with base64 and ZIP_ prefix for complex header values.
*/ */
export function encodeHeaderValue(value: string): string { export function encodeHeaderValue(value: string): string {
if (typeof btoa === 'function') { if (typeof btoa === "function") {
return 'ZIP_' + btoa(value); return "ZIP_" + btoa(value);
} }
return 'ZIP_' + Buffer.from(value, 'utf-8').toString('base64'); return "ZIP_" + Buffer.from(value, "utf-8").toString("base64");
} }
/** /**
* Decode a header value that may be base64 encoded with ZIP_ or __ prefix. * Decode a header value that may be base64 encoded with ZIP_ or __ prefix.
*/ */
export function decodeHeaderValue(value: string): string { export function decodeHeaderValue(value: string): string {
let code = value; let code = value;
if (code.startsWith('ZIP_')) { if (code.startsWith("ZIP_")) {
code = code.slice(4).replace(/[\n\r ]/g, ''); code = code.slice(4).replace(/[\n\r ]/g, "");
code = decodeBase64(code); code = decodeBase64(code);
} else if (code.startsWith('__')) { } else if (code.startsWith("__")) {
code = code.slice(2).replace(/[\n\r ]/g, ''); code = code.slice(2).replace(/[\n\r ]/g, "");
code = decodeBase64(code); code = decodeBase64(code);
} }
// Handle nested encoding // Handle nested encoding
if (code.startsWith('ZIP_') || code.startsWith('__')) { if (code.startsWith("ZIP_") || code.startsWith("__")) {
code = decodeHeaderValue(code); code = decodeHeaderValue(code);
} }
return code; return code;
} }
function decodeBase64(str: string): string { function decodeBase64(str: string): string {
if (typeof atob === 'function') { if (typeof atob === "function") {
return atob(str); return atob(str);
} }
return Buffer.from(str, 'base64').toString('utf-8'); return Buffer.from(str, "base64").toString("utf-8");
} }
/** /**
@@ -65,134 +65,151 @@ function decodeBase64(str: string): string {
* - X-Custom-SQL-W: custom operators (AND) * - X-Custom-SQL-W: custom operators (AND)
*/ */
export function buildHeaders(options: Options): Record<string, string> { export function buildHeaders(options: Options): Record<string, string> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
// Column selection // Column selection
if (options.columns?.length) { if (options.columns?.length) {
headers['X-Select-Fields'] = options.columns.join(','); headers["X-Select-Fields"] = options.columns.join(",");
} }
if (options.omit_columns?.length) { if (options.omit_columns?.length) {
headers['X-Not-Select-Fields'] = options.omit_columns.join(','); headers["X-Not-Select-Fields"] = options.omit_columns.join(",");
} }
// Filters // Filters
if (options.filters?.length) { if (options.filters?.length) {
for (const filter of options.filters) { for (const filter of options.filters) {
const logicOp = filter.logic_operator ?? 'AND'; const logicOp = filter.logic_operator ?? "AND";
const op = mapOperatorToHeaderOp(filter.operator); const op = mapOperatorToHeaderOp(filter.operator);
const valueStr = formatFilterValue(filter); const valueStr = formatFilterValue(filter);
if (filter.operator === 'eq' && logicOp === 'AND') { if (filter.operator === "eq" && logicOp === "AND") {
// Simple field filter shorthand // Simple field filter shorthand
headers[`X-FieldFilter-${filter.column}`] = valueStr; headers[`X-FieldFilter-${filter.column}`] = valueStr;
} else if (logicOp === 'OR') { } else if (logicOp === "OR") {
headers[`X-SearchOr-${op}-${filter.column}`] = valueStr; headers[`X-SearchOr-${op}-${filter.column}`] = valueStr;
} else { } else {
headers[`X-SearchOp-${op}-${filter.column}`] = valueStr; headers[`X-SearchOp-${op}-${filter.column}`] = valueStr;
} }
}
} }
}
// Sort // Sort
if (options.sort?.length) { if (options.sort?.length) {
const sortParts = options.sort.map((s: SortOption) => { const sortParts = options.sort.map((s: SortOption) => {
const dir = s.direction.toUpperCase(); const dir = s.direction.toUpperCase();
return dir === 'DESC' ? `-${s.column}` : `+${s.column}`; return dir === "DESC" ? `-${s.column}` : `+${s.column}`;
}); });
headers['X-Sort'] = sortParts.join(','); headers["X-Sort"] = sortParts.join(",");
} }
// Pagination // Pagination
if (options.limit !== undefined) { if (options.limit !== undefined) {
headers['X-Limit'] = String(options.limit); headers["X-Limit"] = String(options.limit);
} }
if (options.offset !== undefined) { if (options.offset !== undefined) {
headers['X-Offset'] = String(options.offset); headers["X-Offset"] = String(options.offset);
} }
// Cursor pagination // Cursor pagination
if (options.cursor_forward) { if (options.cursor_forward) {
headers['X-Cursor-Forward'] = options.cursor_forward; headers["X-Cursor-Forward"] = options.cursor_forward;
} }
if (options.cursor_backward) { if (options.cursor_backward) {
headers['X-Cursor-Backward'] = options.cursor_backward; headers["X-Cursor-Backward"] = options.cursor_backward;
} }
// Preload // Preload
if (options.preload?.length) { if (options.preload?.length) {
const parts = options.preload.map((p: PreloadOption) => { const parts = options.preload.map((p: PreloadOption) => {
if (p.columns?.length) { if (p.columns?.length) {
return `${p.relation}:${p.columns.join(',')}`; return `${p.relation}:${p.columns.join(",")}`;
} }
return p.relation; return p.relation;
}); });
headers['X-Preload'] = parts.join('|'); headers["X-Preload"] = parts.join("|");
} }
// Fetch row number // Fetch row number
if (options.fetch_row_number) { if (options.fetch_row_number) {
headers['X-Fetch-RowNumber'] = options.fetch_row_number; headers["X-Fetch-RowNumber"] = options.fetch_row_number;
} }
// Computed columns // Computed columns
if (options.computedColumns?.length) { if (options.computedColumns?.length) {
for (const cc of options.computedColumns) { for (const cc of options.computedColumns) {
headers[`X-CQL-SEL-${cc.name}`] = cc.expression; headers[`X-CQL-SEL-${cc.name}`] = cc.expression;
}
} }
}
// Custom operators -> X-Custom-SQL-W // Custom operators -> X-Custom-SQL-W
if (options.customOperators?.length) { if (options.customOperators?.length) {
const sqlParts = options.customOperators.map((co: CustomOperator) => co.sql); const sqlParts = options.customOperators.map(
headers['X-Custom-SQL-W'] = sqlParts.join(' AND '); (co: CustomOperator) => co.sql,
} );
headers["X-Custom-SQL-W"] = sqlParts.join(" AND ");
}
return headers; return headers;
} }
function mapOperatorToHeaderOp(operator: string): string { function mapOperatorToHeaderOp(operator: string): string {
switch (operator) { switch (operator) {
case 'eq': return 'equals'; case "eq":
case 'neq': return 'notequals'; return "equals";
case 'gt': return 'greaterthan'; case "neq":
case 'gte': return 'greaterthanorequal'; return "notequals";
case 'lt': return 'lessthan'; case "gt":
case 'lte': return 'lessthanorequal'; return "greaterthan";
case 'like': case "gte":
case 'ilike': return "greaterthanorequal";
case 'contains': return 'contains'; case "lt":
case 'startswith': return 'beginswith'; return "lessthan";
case 'endswith': return 'endswith'; case "lte":
case 'in': return 'in'; return "lessthanorequal";
case 'between': return 'between'; case "like":
case 'between_inclusive': return 'betweeninclusive'; case "ilike":
case 'is_null': return 'empty'; case "contains":
case 'is_not_null': return 'notempty'; return "contains";
default: return operator; 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 operator;
}
} }
function formatFilterValue(filter: FilterOption): string { function formatFilterValue(filter: FilterOption): string {
if (filter.value === null || filter.value === undefined) { if (filter.value === null || filter.value === undefined) {
return ''; return "";
} }
if (Array.isArray(filter.value)) { if (Array.isArray(filter.value)) {
return filter.value.join(','); return filter.value.join(",");
} }
return String(filter.value); return String(filter.value);
} }
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 = config.baseUrl;
let instance = instances.get(key); let instance = instances.get(key);
if (!instance) { if (!instance) {
instance = new HeaderSpecClient(config); instance = new HeaderSpecClient(config);
instances.set(key, instance); instances.set(key, instance);
} }
return instance; return instance;
} }
/** /**
@@ -202,95 +219,127 @@ export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient {
* HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete * HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
*/ */
export class HeaderSpecClient { export class HeaderSpecClient {
private config: ClientConfig; private config: ClientConfig;
constructor(config: ClientConfig) { constructor(config: ClientConfig) {
this.config = config; this.config = config;
}
private buildUrl(schema: string, entity: string, id?: string): string {
let url = `${this.config.baseUrl}/${schema}/${entity}`;
if (id) {
url += `/${id}`;
}
return url;
}
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;
}
private async fetchWithError<T>(
url: string,
init: RequestInit,
): Promise<APIResponse<T>> {
const response = await fetch(url, init);
const data = await response.json();
if (!response.ok) {
throw new Error(
data.error?.message ||
`${response.statusText} ` + `(${response.status})`,
);
} }
private buildUrl(schema: string, entity: string, id?: string): string { return {
let url = `${this.config.baseUrl}/${schema}/${entity}`; data: data,
if (id) { success: true,
url += `/${id}`; error: data.error ? data.error : undefined,
} metadata: {
return url; count: response.headers.get("content-range")
} ? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
total: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
filtered: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
offset: response.headers.get("content-range")
? Number(
response.headers
.get("content-range")
?.split("/")[0]
.split("-")[0],
)
: 0,
limit: response.headers.get("x-limit")
? Number(response.headers.get("x-limit"))
: 0,
},
};
}
private baseHeaders(): Record<string, string> { async read<T = any>(
const headers: Record<string, string> = { schema: string,
'Content-Type': 'application/json', entity: string,
}; id?: string,
if (this.config.token) { options?: Options,
headers['Authorization'] = `Bearer ${this.config.token}`; ): Promise<APIResponse<T>> {
} const url = this.buildUrl(schema, entity, id);
return headers; const optHeaders = options ? buildHeaders(options) : {};
} return this.fetchWithError<T>(url, {
method: "GET",
headers: { ...this.baseHeaders(), ...optHeaders },
});
}
private async fetchWithError<T>(url: string, init: RequestInit): Promise<APIResponse<T>> { async create<T = any>(
const response = await fetch(url, init); schema: string,
const data = await response.json(); entity: string,
data: any,
options?: Options,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "POST",
headers: { ...this.baseHeaders(), ...optHeaders },
body: JSON.stringify(data),
});
}
if (!response.ok) { async update<T = any>(
throw new Error(data.error?.message || 'An error occurred'); schema: string,
} entity: string,
id: string,
data: any,
options?: Options,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity, id);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "PUT",
headers: { ...this.baseHeaders(), ...optHeaders },
body: JSON.stringify(data),
});
}
return data; async delete(
} schema: string,
entity: string,
async read<T = any>( id: string,
schema: string, ): Promise<APIResponse<void>> {
entity: string, const url = this.buildUrl(schema, entity, id);
id?: string, return this.fetchWithError<void>(url, {
options?: Options method: "DELETE",
): Promise<APIResponse<T>> { headers: this.baseHeaders(),
const url = this.buildUrl(schema, entity, id); });
const optHeaders = options ? buildHeaders(options) : {}; }
return this.fetchWithError<T>(url, {
method: 'GET',
headers: { ...this.baseHeaders(), ...optHeaders },
});
}
async create<T = any>(
schema: string,
entity: string,
data: any,
options?: Options
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: { ...this.baseHeaders(), ...optHeaders },
body: JSON.stringify(data),
});
}
async update<T = any>(
schema: string,
entity: string,
id: string,
data: any,
options?: Options
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity, id);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: 'PUT',
headers: { ...this.baseHeaders(), ...optHeaders },
body: JSON.stringify(data),
});
}
async delete(
schema: string,
entity: string,
id: string
): Promise<APIResponse<void>> {
const url = this.buildUrl(schema, entity, id);
return this.fetchWithError<void>(url, {
method: 'DELETE',
headers: this.baseHeaders(),
});
}
} }