mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 04:21:58 +00:00
feat(resolvespec-js): support extended restheadspec headers
Add HeaderSpecOptions, vector_search, X-Preload-Where, X-Expand, custom SQL joins/or, spatial/vector filters, response format, flags and X-Files to buildHeaders, types and README.
This commit is contained in:
@@ -106,6 +106,25 @@ await client.delete('public', 'users', '42');
|
||||
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
|
||||
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
|
||||
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
|
||||
| `X-Preload-Where` | `preload[].where` | applies to all preloads in `X-Preload`; differing wheres go to `X-Preload-{n}` + `X-Preload-{n}-Where` |
|
||||
| `X-Expand` | `expand` | `Rel:col1,col2` pipe-separated (LEFT JOIN) |
|
||||
| `X-Custom-SQL-Join` | `custom_sql_joins` | JOIN clauses, pipe-separated |
|
||||
| `X-Custom-SQL-Or` | `custom_sql_or` | SQL OR-joined |
|
||||
| `X-SearchCols` | `search_columns` | comma-separated |
|
||||
| `X-AdvSQL-{col}` | `advanced_sql` | column -> SQL |
|
||||
| `X-SpatialFilter-{col}` | `filters` (`st_dwithin`, `st_*`, `bbox`) | JSON `{op,value,logic}` |
|
||||
| `X-VectorFilter-{col}` | `filters` (`l2_within`, `cosine_within`, `ip_within`) | JSON `{op,value,logic}` |
|
||||
| `X-Vector-Search-{col}` / `-Vector` / `-As` / `-Dir` | `vector_search` | metric / JSON array / alias / asc\|desc |
|
||||
| `X-Clean-JSON` | `clean_json` | bool |
|
||||
| `X-Distinct` | `distinct` | bool |
|
||||
| `X-SkipCount` / `X-SkipCache` | `skip_count` / `skip_cache` | bool |
|
||||
| `X-PKRow` | `pk_row` | string |
|
||||
| `X-SimpleApi` / `X-DetailApi` / `X-Syncfusion` | `response_format` | `simple` \| `detail` \| `syncfusion` |
|
||||
| `X-Single-Record-As-Object` | `single_record_as_object` | bool (server default true) |
|
||||
| `X-Transaction-Atomic` | `atomic_transaction` | bool |
|
||||
| `X-Files` | `xfiles` | JSON, sent as `ZIP_` base64 |
|
||||
|
||||
Extended fields live on `HeaderSpecOptions` (extends `Options`); `vector_search` is on `Options`.
|
||||
|
||||
### Utility Functions
|
||||
|
||||
|
||||
@@ -2,6 +2,101 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildHeaders, encodeHeaderValue, decodeHeaderValue, HeaderSpecClient, getHeaderSpecClient } from '../headerspec/client';
|
||||
import type { Options, ClientConfig, APIResponse } from '../common/types';
|
||||
|
||||
describe('buildHeaders (extended restheadspec options)', () => {
|
||||
it('should set X-Preload-Where when all preloads share one where', () => {
|
||||
const h = buildHeaders({
|
||||
preload: [
|
||||
{ relation: 'Items', columns: ['id'], where: 'active = true' },
|
||||
{ relation: 'Tags', where: 'active = true' },
|
||||
],
|
||||
});
|
||||
expect(h['X-Preload']).toBe('Items:id|Tags');
|
||||
expect(h['X-Preload-Where']).toBe('active = true');
|
||||
});
|
||||
|
||||
it('should use numbered headers for mixed where clauses', () => {
|
||||
const h = buildHeaders({
|
||||
preload: [
|
||||
{ relation: 'Items', where: 'a = 1' },
|
||||
{ relation: 'Category' },
|
||||
{ relation: 'Tags', where: 'b = 2' },
|
||||
],
|
||||
});
|
||||
expect(h['X-Preload']).toBe('Category');
|
||||
expect(h['X-Preload-Where']).toBeUndefined();
|
||||
expect(h['X-Preload-1']).toBe('Items');
|
||||
expect(h['X-Preload-1-Where']).toBe('a = 1');
|
||||
expect(h['X-Preload-2']).toBe('Tags');
|
||||
expect(h['X-Preload-2-Where']).toBe('b = 2');
|
||||
});
|
||||
|
||||
it('should set expand, joins, or-sql, search cols, advsql', () => {
|
||||
const h = buildHeaders({
|
||||
expand: [{ relation: 'Dept', columns: ['id', 'name'] }, { relation: 'Role' }],
|
||||
custom_sql_joins: ['LEFT JOIN a ON a.id = b.id', 'INNER JOIN c ON c.id = b.cid'],
|
||||
custom_sql_or: ['x = 1', 'y = 2'],
|
||||
search_columns: ['name', 'email'],
|
||||
advanced_sql: { total: 'a + b' },
|
||||
});
|
||||
expect(h['X-Expand']).toBe('Dept:id,name|Role');
|
||||
expect(h['X-Custom-SQL-Join']).toBe('LEFT JOIN a ON a.id = b.id|INNER JOIN c ON c.id = b.cid');
|
||||
expect(h['X-Custom-SQL-Or']).toBe('x = 1 OR y = 2');
|
||||
expect(h['X-SearchCols']).toBe('name,email');
|
||||
expect(h['X-AdvSQL-total']).toBe('a + b');
|
||||
});
|
||||
|
||||
it('should set boolean flags, pk row and response format', () => {
|
||||
const h = buildHeaders({
|
||||
clean_json: true,
|
||||
distinct: true,
|
||||
skip_count: true,
|
||||
skip_cache: false,
|
||||
atomic_transaction: true,
|
||||
single_record_as_object: false,
|
||||
pk_row: '42',
|
||||
response_format: 'detail',
|
||||
});
|
||||
expect(h['X-Clean-JSON']).toBe('true');
|
||||
expect(h['X-Distinct']).toBe('true');
|
||||
expect(h['X-SkipCount']).toBe('true');
|
||||
expect(h['X-SkipCache']).toBe('false');
|
||||
expect(h['X-Transaction-Atomic']).toBe('true');
|
||||
expect(h['X-Single-Record-As-Object']).toBe('false');
|
||||
expect(h['X-PKRow']).toBe('42');
|
||||
expect(h['X-DetailApi']).toBe('true');
|
||||
});
|
||||
|
||||
it('should set spatial and vector filters as JSON', () => {
|
||||
const h = buildHeaders({
|
||||
filters: [
|
||||
{ column: 'geom', operator: 'st_dwithin', value: { geom: 'POINT(0 0)', distance: 5 }, logic_operator: 'OR' },
|
||||
{ column: 'emb', operator: 'cosine_within', value: { vector: [1, 2], distance: 0.3 } },
|
||||
],
|
||||
});
|
||||
expect(JSON.parse(h['X-SpatialFilter-geom'])).toEqual({
|
||||
op: 'st_dwithin', value: { geom: 'POINT(0 0)', distance: 5 }, logic: 'or',
|
||||
});
|
||||
expect(JSON.parse(h['X-VectorFilter-emb']).op).toBe('cosine_within');
|
||||
});
|
||||
|
||||
it('should set vector search headers', () => {
|
||||
const h = buildHeaders({
|
||||
vector_search: { column: 'emb', vector: [0.1, 0.2], metric: 'cosine', as: 'dist', direction: 'desc' },
|
||||
});
|
||||
expect(h['X-Vector-Search-emb']).toBe('cosine');
|
||||
expect(h['X-Vector-Search-Vector']).toBe('[0.1,0.2]');
|
||||
expect(h['X-Vector-Search-As']).toBe('dist');
|
||||
expect(h['X-Vector-Search-Dir']).toBe('desc');
|
||||
});
|
||||
|
||||
it('should encode X-Files as ZIP_ JSON', () => {
|
||||
const xf = { tablename: 'users', prefix: 'USR', limit: 10 };
|
||||
const h = buildHeaders({ xfiles: xf });
|
||||
expect(h['X-Files'].startsWith('ZIP_')).toBe(true);
|
||||
expect(JSON.parse(decodeHeaderValue(h['X-Files']))).toEqual(xf);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaders', () => {
|
||||
it('should set X-Select-Fields for columns', () => {
|
||||
const h = buildHeaders({ columns: ['id', 'name', 'email'] });
|
||||
|
||||
@@ -5,7 +5,11 @@ export type Operator =
|
||||
| 'like' | 'ilike' | 'in'
|
||||
| 'contains' | 'startswith' | 'endswith'
|
||||
| 'between' | 'between_inclusive'
|
||||
| 'is_null' | 'is_not_null';
|
||||
| 'is_null' | 'is_not_null'
|
||||
// PostGIS spatial (sent via X-SpatialFilter-{col})
|
||||
| 'st_dwithin' | 'bbox'
|
||||
// pgvector similarity (sent via X-VectorFilter-{col})
|
||||
| 'l2_within' | 'cosine_within' | 'ip_within';
|
||||
|
||||
export type Operation = 'read' | 'create' | 'update' | 'delete';
|
||||
export type SortDirection = 'asc' | 'desc' | 'ASC' | 'DESC';
|
||||
@@ -61,6 +65,54 @@ export interface ComputedColumn {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export type VectorMetric = 'l2' | 'cosine' | 'ip';
|
||||
export type ResponseFormat = 'simple' | 'detail' | 'syncfusion';
|
||||
|
||||
/** pgvector KNN search: order by distance between `column` and `vector`. */
|
||||
export interface VectorSearchOption {
|
||||
column: string;
|
||||
vector: number[];
|
||||
metric?: VectorMetric;
|
||||
/** Distance column alias. Default `_distance` */
|
||||
as?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** LEFT JOIN expansion of a relation (X-Expand). */
|
||||
export interface ExpandOption {
|
||||
relation: string;
|
||||
columns?: string[];
|
||||
}
|
||||
|
||||
/** X-Files configuration (Go restheadspec XFiles). Sent as a single JSON header. */
|
||||
export interface XFiles {
|
||||
tablename?: string;
|
||||
schema?: string;
|
||||
primarykey?: string;
|
||||
foreignkey?: string;
|
||||
relatedkey?: string;
|
||||
sort?: string[];
|
||||
prefix?: string;
|
||||
editable?: boolean;
|
||||
recursive?: boolean;
|
||||
expand?: boolean;
|
||||
rownumber?: boolean;
|
||||
skipcount?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
columns?: string[];
|
||||
omit_columns?: string[];
|
||||
cql_columns?: string[];
|
||||
sql_joins?: string[];
|
||||
sql_or?: string[];
|
||||
sql_and?: string[];
|
||||
parenttables?: XFiles[];
|
||||
childtables?: XFiles[];
|
||||
filter_fields?: { field: string; value: string; operator: string }[];
|
||||
cursor_forward?: string;
|
||||
cursor_backward?: string;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
preload?: PreloadOption[];
|
||||
columns?: string[];
|
||||
@@ -75,6 +127,39 @@ export interface Options {
|
||||
cursor_forward?: string;
|
||||
cursor_backward?: string;
|
||||
fetch_row_number?: string;
|
||||
vector_search?: VectorSearchOption;
|
||||
}
|
||||
|
||||
/** Options only available to the header-based (restheadspec) protocol. */
|
||||
export interface HeaderSpecOptions extends Options {
|
||||
/** X-Expand: LEFT JOIN relations */
|
||||
expand?: ExpandOption[];
|
||||
/** X-Custom-SQL-Join: raw JOIN clauses */
|
||||
custom_sql_joins?: string[];
|
||||
/** X-Custom-SQL-Or: raw SQL, OR-combined */
|
||||
custom_sql_or?: string[];
|
||||
/** X-SearchCols: columns for multi-column search */
|
||||
search_columns?: string[];
|
||||
/** X-AdvSQL-{col}: column -> SQL expression */
|
||||
advanced_sql?: Record<string, string>;
|
||||
/** X-Clean-JSON */
|
||||
clean_json?: boolean;
|
||||
/** X-Distinct */
|
||||
distinct?: boolean;
|
||||
/** X-SkipCount: skip total count query */
|
||||
skip_count?: boolean;
|
||||
/** X-SkipCache */
|
||||
skip_cache?: boolean;
|
||||
/** X-PKRow: primary key value of a row to fetch */
|
||||
pk_row?: string;
|
||||
/** X-SimpleApi / X-DetailApi / X-Syncfusion */
|
||||
response_format?: ResponseFormat;
|
||||
/** X-Single-Record-As-Object (server default true) */
|
||||
single_record_as_object?: boolean;
|
||||
/** X-Transaction-Atomic */
|
||||
atomic_transaction?: boolean;
|
||||
/** X-Files: single JSON configuration */
|
||||
xfiles?: XFiles;
|
||||
}
|
||||
|
||||
export interface RequestBody {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ClientConfig,
|
||||
CustomOperator,
|
||||
FilterOption,
|
||||
Options,
|
||||
HeaderSpecOptions,
|
||||
PreloadOption,
|
||||
SortOption,
|
||||
} from "../common/types";
|
||||
@@ -59,8 +59,15 @@ function decodeBase64(str: string): string {
|
||||
* - X-Fetch-RowNumber: row number fetch
|
||||
* - X-CQL-SEL-{col}: computed columns
|
||||
* - X-Custom-SQL-W: custom operators (AND)
|
||||
* - X-Preload-Where: where for X-Preload (extra where groups use X-Preload-{n}[-Where])
|
||||
* - X-SpatialFilter-{col} / X-VectorFilter-{col}: JSON {op,value,logic}
|
||||
* - X-Vector-Search-{col|vector|as|dir}: pgvector KNN
|
||||
* - X-Expand, X-Custom-SQL-Join, X-Custom-SQL-Or, X-SearchCols, X-AdvSQL-{col}
|
||||
* - X-Clean-JSON, X-Distinct, X-SkipCount, X-SkipCache, X-PKRow
|
||||
* - X-SimpleApi / X-DetailApi / X-Syncfusion, X-Single-Record-As-Object
|
||||
* - X-Transaction-Atomic, X-Files
|
||||
*/
|
||||
export function buildHeaders(options: Options): Record<string, string> {
|
||||
export function buildHeaders(options: HeaderSpecOptions): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Column selection
|
||||
@@ -79,6 +86,17 @@ export function buildHeaders(options: Options): Record<string, string> {
|
||||
const op = mapOperatorToHeaderOp(filter.operator);
|
||||
const valueStr = formatFilterValue(filter);
|
||||
|
||||
const geoPrefix = geoFilterHeader(filter.operator);
|
||||
if (geoPrefix) {
|
||||
const payload: Record<string, unknown> = {
|
||||
op: filter.operator,
|
||||
value: filter.value,
|
||||
};
|
||||
if (logicOp === "OR") payload.logic = "or";
|
||||
headers[`${geoPrefix}${filter.column}`] = JSON.stringify(payload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.operator === "eq" && logicOp === "AND") {
|
||||
// Simple field filter shorthand
|
||||
headers[`X-FieldFilter-${filter.column}`] = valueStr;
|
||||
@@ -117,13 +135,94 @@ export function buildHeaders(options: Options): Record<string, string> {
|
||||
|
||||
// Preload
|
||||
if (options.preload?.length) {
|
||||
const parts = options.preload.map((p: PreloadOption) => {
|
||||
if (p.columns?.length) {
|
||||
return `${p.relation}:${p.columns.join(",")}`;
|
||||
// Go applies X-Preload-Where to every preload in the matching X-Preload header,
|
||||
// so preloads are grouped by where clause.
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const p of options.preload) {
|
||||
const spec = p.columns?.length
|
||||
? `${p.relation}:${p.columns.join(",")}`
|
||||
: p.relation;
|
||||
const where = p.where ?? "";
|
||||
groups.set(where, [...(groups.get(where) ?? []), spec]);
|
||||
}
|
||||
let n = 0;
|
||||
for (const [where, specs] of groups) {
|
||||
if (!where) {
|
||||
headers["X-Preload"] = specs.join("|");
|
||||
} else if (!groups.has("") && n === 0) {
|
||||
// X-Preload-Where would also apply to a where-less X-Preload, so only use it alone
|
||||
headers["X-Preload"] = specs.join("|");
|
||||
headers["X-Preload-Where"] = where;
|
||||
n++;
|
||||
} else {
|
||||
n++;
|
||||
headers[`X-Preload-${n}`] = specs.join("|");
|
||||
headers[`X-Preload-${n}-Where`] = where;
|
||||
}
|
||||
return p.relation;
|
||||
});
|
||||
headers["X-Preload"] = parts.join("|");
|
||||
}
|
||||
}
|
||||
|
||||
// Expand (LEFT JOIN)
|
||||
if (options.expand?.length) {
|
||||
headers["X-Expand"] = options.expand
|
||||
.map((e) =>
|
||||
e.columns?.length ? `${e.relation}:${e.columns.join(",")}` : e.relation,
|
||||
)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
if (options.custom_sql_joins?.length) {
|
||||
headers["X-Custom-SQL-Join"] = options.custom_sql_joins.join("|");
|
||||
}
|
||||
if (options.custom_sql_or?.length) {
|
||||
headers["X-Custom-SQL-Or"] = options.custom_sql_or.join(" OR ");
|
||||
}
|
||||
if (options.search_columns?.length) {
|
||||
headers["X-SearchCols"] = options.search_columns.join(",");
|
||||
}
|
||||
if (options.advanced_sql) {
|
||||
for (const [col, sql] of Object.entries(options.advanced_sql)) {
|
||||
headers[`X-AdvSQL-${col}`] = sql;
|
||||
}
|
||||
}
|
||||
|
||||
// pgvector KNN search
|
||||
if (options.vector_search) {
|
||||
const vs = options.vector_search;
|
||||
headers[`X-Vector-Search-${vs.column}`] = vs.metric ?? "l2";
|
||||
headers["X-Vector-Search-Vector"] = JSON.stringify(vs.vector);
|
||||
if (vs.as) headers["X-Vector-Search-As"] = vs.as;
|
||||
if (vs.direction) headers["X-Vector-Search-Dir"] = vs.direction;
|
||||
}
|
||||
|
||||
// Flags
|
||||
const flags: [string, boolean | undefined][] = [
|
||||
["X-Clean-JSON", options.clean_json],
|
||||
["X-Distinct", options.distinct],
|
||||
["X-SkipCount", options.skip_count],
|
||||
["X-SkipCache", options.skip_cache],
|
||||
["X-Transaction-Atomic", options.atomic_transaction],
|
||||
["X-Single-Record-As-Object", options.single_record_as_object],
|
||||
];
|
||||
for (const [name, val] of flags) {
|
||||
if (val !== undefined) headers[name] = String(val);
|
||||
}
|
||||
|
||||
if (options.pk_row) {
|
||||
headers["X-PKRow"] = options.pk_row;
|
||||
}
|
||||
|
||||
if (options.response_format) {
|
||||
const formatHeaders = {
|
||||
simple: "X-SimpleApi",
|
||||
detail: "X-DetailApi",
|
||||
syncfusion: "X-Syncfusion",
|
||||
} as const;
|
||||
headers[formatHeaders[options.response_format]] = "true";
|
||||
}
|
||||
|
||||
if (options.xfiles) {
|
||||
headers["X-Files"] = encodeHeaderValue(JSON.stringify(options.xfiles));
|
||||
}
|
||||
|
||||
// Fetch row number
|
||||
@@ -149,6 +248,17 @@ export function buildHeaders(options: Options): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const VECTOR_OPS = new Set(["l2_within", "cosine_within", "ip_within"]);
|
||||
|
||||
function geoFilterHeader(operator: string): string | null {
|
||||
const op = operator.toLowerCase();
|
||||
if (VECTOR_OPS.has(op) || op.endsWith("_within")) return "X-VectorFilter-";
|
||||
if (op.startsWith("st_") || op === "bbox" || op === "&&") {
|
||||
return "X-SpatialFilter-";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapOperatorToHeaderOp(operator: string): string {
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
@@ -280,7 +390,7 @@ export class HeaderSpecClient {
|
||||
schema: string,
|
||||
entity: string,
|
||||
id?: string,
|
||||
options?: Options,
|
||||
options?: HeaderSpecOptions,
|
||||
): Promise<APIResponse<T>> {
|
||||
const url = this.buildUrl(schema, entity, id);
|
||||
const optHeaders = options ? buildHeaders(options) : {};
|
||||
@@ -294,7 +404,7 @@ export class HeaderSpecClient {
|
||||
schema: string,
|
||||
entity: string,
|
||||
data: any,
|
||||
options?: Options,
|
||||
options?: HeaderSpecOptions,
|
||||
): Promise<APIResponse<T>> {
|
||||
const url = this.buildUrl(schema, entity);
|
||||
const optHeaders = options ? buildHeaders(options) : {};
|
||||
@@ -310,7 +420,7 @@ export class HeaderSpecClient {
|
||||
entity: string,
|
||||
id: string,
|
||||
data: any,
|
||||
options?: Options,
|
||||
options?: HeaderSpecOptions,
|
||||
): Promise<APIResponse<T>> {
|
||||
const url = this.buildUrl(schema, entity, id);
|
||||
const optHeaders = options ? buildHeaders(options) : {};
|
||||
|
||||
Reference in New Issue
Block a user