refactor: move JS and Python clients under clients/

This commit is contained in:
2026-09-30 22:31:43 +02:00
parent f6a9daa89e
commit b2b815552f
47 changed files with 7 additions and 7 deletions
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
+14
View File
@@ -0,0 +1,14 @@
# @warkypublic/resolvespec-js
## 1.0.2
### Patch Changes
- b587cbd: 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.
- 7f8982f: fix: added headers and few fixes
## 1.0.1
### Patch Changes
- Fixed headerpsec
+132
View File
@@ -0,0 +1,132 @@
# ResolveSpec JS - Implementation Plan
TypeScript client library for ResolveSpec, RestHeaderSpec, WebSocket and MQTT APIs.
---
## Status
| Phase | Description | Status |
|-------|-------------|--------|
| 0 | Restructure into folders | Done |
| 1 | Fix types (align with Go) | Done |
| 2 | Fix REST client | Done |
| 3 | Build config | Done |
| 4 | Tests | Done |
| 5 | HeaderSpec client | Done |
| 6 | MQTT client | Planned |
| 6.5 | Unified class pattern + singleton factories | Done |
| 7 | Response cache (TTL) | Planned |
| 8 | TanStack Query integration | Planned |
| 9 | React Hooks | Planned |
**Build:** `dist/index.js` (ES) + `dist/index.cjs` (CJS) + `.d.ts` declarations
**Tests:** 65 passing (common: 10, resolvespec: 13, websocketspec: 15, headerspec: 27)
---
## Folder Structure
```
src/
├── common/
│ ├── types.ts # Core types aligned with Go pkg/common/types.go
│ └── index.ts
├── resolvespec/
│ ├── client.ts # ResolveSpecClient class + createResolveSpecClient singleton
│ └── index.ts
├── headerspec/
│ ├── client.ts # HeaderSpecClient class + createHeaderSpecClient singleton + buildHeaders utility
│ └── index.ts
├── websocketspec/
│ ├── types.ts # WS-specific types (WSMessage, WSOptions, etc.)
│ ├── client.ts # WebSocketClient class + createWebSocketClient singleton
│ └── index.ts
├── mqttspec/ # Future
│ ├── types.ts
│ ├── client.ts
│ └── index.ts
├── __tests__/
│ ├── common.test.ts
│ ├── resolvespec.test.ts
│ ├── headerspec.test.ts
│ └── websocketspec.test.ts
└── index.ts # Root barrel export
```
---
## Type Alignment with Go
Types in `src/common/types.ts` match `pkg/common/types.go`:
- **Operator**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, `contains`, `startswith`, `endswith`, `between`, `between_inclusive`, `is_null`, `is_not_null`
- **FilterOption**: `column`, `operator`, `value`, `logic_operator` (AND/OR)
- **Options**: `columns`, `omit_columns`, `filters`, `sort`, `limit`, `offset`, `preload`, `customOperators`, `computedColumns`, `parameters`, `cursor_forward`, `cursor_backward`, `fetch_row_number`
- **PreloadOption**: `relation`, `table_name`, `columns`, `omit_columns`, `sort`, `filters`, `where`, `limit`, `offset`, `updatable`, `recursive`, `computed_ql`, `primary_key`, `related_key`, `foreign_key`, `recursive_child_key`, `sql_joins`, `join_aliases`
- **Parameter**: `name`, `value`, `sequence?`
- **Metadata**: `total`, `count`, `filtered`, `limit`, `offset`, `row_number?`
- **APIError**: `code`, `message`, `details?`, `detail?`
---
## HeaderSpec Header Mapping
Maps Options to HTTP headers per Go `restheadspec/headers.go`:
| Header | Options field | Format |
|--------|--------------|--------|
| `X-Select-Fields` | `columns` | comma-separated |
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
| `X-Sort` | `sort` | `+col` (asc), `-col` (desc) |
| `X-Limit` | `limit` | number |
| `X-Offset` | `offset` | number |
| `X-Cursor-Forward` | `cursor_forward` | string |
| `X-Cursor-Backward` | `cursor_backward` | string |
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
Complex values use `ZIP_` + base64 encoding.
HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete.
---
## Build & Test
```bash
pnpm install
pnpm run build # vite library mode → dist/
pnpm run test # vitest
pnpm run lint # eslint
```
**Config files:** `tsconfig.json` (ES2020, strict, bundler), `vite.config.ts` (lib mode, dts via vite-plugin-dts)
**Externals:** `uuid`, `semver`
---
## Remaining Work
- **Phase 6 — MQTT Client**: Topic-based CRUD over MQTT (optional/future)
- **Phase 7 — Cache**: In-memory response cache with TTL, key = URL + options hash, auto-invalidation on CUD, `skipCache` flag
- **Phase 8 — TanStack Query Integration**: Query/mutation hooks wrapping each client, query key factories, automatic cache invalidation
- **Phase 9 — React Hooks**: `useResolveSpec`, `useHeaderSpec`, `useWebSocket` hooks with provider context, loading/error states
- ESLint config may need updating for new folder structure
---
## Reference Files
| Purpose | Path |
|---------|------|
| Go types (source of truth) | `pkg/common/types.go` |
| Go REST handler | `pkg/resolvespec/handler.go` |
| Go HeaderSpec handler | `pkg/restheadspec/handler.go` |
| Go HeaderSpec header parsing | `pkg/restheadspec/headers.go` |
| Go test models | `pkg/testmodels/business.go` |
| Go tests | `tests/crud_test.go` |
+254
View File
@@ -0,0 +1,254 @@
# ResolveSpec JS
TypeScript client library for ResolveSpec APIs. Supports body-based REST, header-based REST, and WebSocket protocols.
## Install
```bash
pnpm add @warkypublic/resolvespec-js
```
## Clients
| Client | Protocol | Singleton Factory |
| --- | --- | --- |
| `ResolveSpecClient` | REST (body-based) | `getResolveSpecClient(config)` |
| `HeaderSpecClient` | REST (header-based) | `getHeaderSpecClient(config)` |
| `WebSocketClient` | WebSocket | `getWebSocketClient(config)` |
All clients use the class pattern. Singleton factories return cached instances keyed by URL.
## REST Client (Body-Based)
Options sent in JSON request body. Maps to Go `pkg/resolvespec`.
```typescript
import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespec-js';
// Class instantiation
const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// 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
const result = await client.read('public', 'users', undefined, {
columns: ['id', 'name', 'email'],
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
sort: [{ column: 'name', direction: 'asc' }],
limit: 10,
offset: 0,
preload: [{ relation: 'Posts', columns: ['id', 'title'] }],
});
// Read by ID
const user = await client.read('public', 'users', 42);
// Create
const created = await client.create('public', 'users', { name: 'New User' });
// Update
await client.update('public', 'users', { name: 'Updated' }, 42);
// Delete
await client.delete('public', 'users', 42);
// Metadata
const meta = await client.getMetadata('public', 'users');
```
## HeaderSpec Client (Header-Based)
Options sent via HTTP headers. Maps to Go `pkg/restheadspec`.
```typescript
import { HeaderSpecClient, getHeaderSpecClient } from '@warkypublic/resolvespec-js';
const client = new HeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// Or: const client = getHeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// GET with options as headers
const result = await client.read('public', 'users', undefined, {
columns: ['id', 'name'],
filters: [
{ column: 'status', operator: 'eq', value: 'active' },
{ column: 'age', operator: 'gte', value: 18, logic_operator: 'AND' },
],
sort: [{ column: 'name', direction: 'asc' }],
limit: 50,
preload: [{ relation: 'Department', columns: ['id', 'name'] }],
});
// POST create
await client.create('public', 'users', { name: 'New User' });
// PUT update
await client.update('public', 'users', '42', { name: 'Updated' });
// DELETE
await client.delete('public', 'users', '42');
```
### Header Mapping
| Header | Options Field | Format |
| --- | --- | --- |
| `X-Select-Fields` | `columns` | comma-separated |
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
| `X-Sort` | `sort` | `+col` asc, `-col` desc |
| `X-Limit` / `X-Offset` | `limit` / `offset` | number |
| `X-Cursor-Forward` | `cursor_forward` | string |
| `X-Cursor-Backward` | `cursor_backward` | string |
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
| `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
```typescript
import { buildHeaders, encodeHeaderValue, decodeHeaderValue } from '@warkypublic/resolvespec-js';
const headers = buildHeaders({ columns: ['id', 'name'], limit: 10 });
// => { 'X-Select-Fields': 'id,name', 'X-Limit': '10' }
const encoded = encodeHeaderValue('complex value'); // 'ZIP_...'
const decoded = decodeHeaderValue(encoded); // 'complex value'
```
## WebSocket Client
Real-time CRUD with subscriptions. Maps to Go `pkg/websocketspec`.
```typescript
import { WebSocketClient, getWebSocketClient } from '@warkypublic/resolvespec-js';
const ws = new WebSocketClient({
url: 'ws://localhost:8080/ws',
reconnect: true,
heartbeatInterval: 30000,
});
// Or: const ws = getWebSocketClient({ url: 'ws://localhost:8080/ws' });
await ws.connect();
// CRUD
const users = await ws.read('users', { schema: 'public', limit: 10 });
const created = await ws.create('users', { name: 'New' }, { schema: 'public' });
await ws.update('users', '1', { name: 'Updated' });
await ws.delete('users', '1');
// Subscribe to changes
const subId = await ws.subscribe('users', (notification) => {
console.log(notification.operation, notification.data);
});
// Unsubscribe
await ws.unsubscribe(subId);
// Events
ws.on('connect', () => console.log('connected'));
ws.on('disconnect', () => console.log('disconnected'));
ws.on('error', (err) => console.error(err));
ws.disconnect();
```
## Types
All types align with Go `pkg/common/types.go`.
### Key Types
```typescript
interface Options {
columns?: string[];
omit_columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
preload?: PreloadOption[];
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
interface FilterOption {
column: string;
operator: Operator | string;
value: any;
logic_operator?: 'AND' | 'OR';
}
// Operators: eq, neq, gt, gte, lt, lte, like, ilike, in,
// contains, startswith, endswith, between,
// between_inclusive, is_null, is_not_null
interface APIResponse<T> {
success: boolean;
data: T;
metadata?: Metadata;
error?: APIError;
}
```
## Build
```bash
pnpm install
pnpm run build # dist/index.js (ES) + dist/index.cjs (CJS) + .d.ts
pnpm run test # vitest
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.
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
export * from './common';
export * from './resolvespec';
export * from './websocketspec';
export * from './headerspec';
//# sourceMappingURL=index.d.ts.map
+432
View File
@@ -0,0 +1,432 @@
import { v4 as e } from "uuid";
import { b64DecodeUnicode as t, b64EncodeUnicode as n } from "@warkypublic/artemis-kit/base64";
//#region src/common/http.ts
function r(...e) {
let t = {};
for (let n of e) for (let [e, r] of Object.entries(n)) {
for (let n of Object.keys(t)) n.toLowerCase() === e.toLowerCase() && delete t[n];
Object.defineProperty(t, e, {
value: r,
enumerable: !0,
configurable: !0,
writable: !0
});
}
return t;
}
function i(e) {
return r({ "Content-Type": "application/json" }, e.headers ?? {}, e.token ? { Authorization: `Bearer ${e.token}` } : {});
}
function a(e) {
let t = Object.entries(i(e)).map(([e, t]) => [e.toLowerCase(), t]).sort(([e], [t]) => e.localeCompare(t));
return JSON.stringify([e.baseUrl, t]);
}
//#endregion
//#region src/resolvespec/client.ts
var o = /* @__PURE__ */ new Map();
function s(e) {
let t = a(e), n = o.get(t);
return n || (n = new c(e), o.set(t, n)), n;
}
var c = class {
constructor(e) {
this.config = {
...e,
headers: { ...e.headers }
};
}
buildUrl(e, t, n) {
let r = `${this.config.baseUrl}/${e}/${t}`;
return n && (r += `/${n}`), r;
}
baseHeaders() {
return i(this.config);
}
async fetchWithError(e, t) {
let n = await fetch(e, t), r = await n.json();
if (!n.ok) throw Error(r.error?.message || "An error occurred");
return r;
}
async getMetadata(e, t) {
let n = this.buildUrl(e, t);
return this.fetchWithError(n, {
method: "GET",
headers: this.baseHeaders()
});
}
async read(e, t, n, r) {
let i = typeof n == "number" || typeof n == "string" ? String(n) : void 0, a = this.buildUrl(e, t, i), o = {
operation: "read",
id: Array.isArray(n) ? n : void 0,
options: r
};
return this.fetchWithError(a, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(o)
});
}
async create(e, t, n, r) {
let i = this.buildUrl(e, t), a = {
operation: "create",
data: n,
options: r
};
return this.fetchWithError(i, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(a)
});
}
async update(e, t, n, r, i) {
let a = typeof r == "number" || typeof r == "string" ? String(r) : void 0, o = this.buildUrl(e, t, a), s = {
operation: "update",
id: Array.isArray(r) ? r : void 0,
data: n,
options: i
};
return this.fetchWithError(o, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify(s)
});
}
async delete(e, t, n) {
let r = this.buildUrl(e, t, String(n));
return this.fetchWithError(r, {
method: "POST",
headers: this.baseHeaders(),
body: JSON.stringify({ operation: "delete" })
});
}
}, l = /* @__PURE__ */ new Map();
function u(e) {
let t = e.url, n = l.get(t);
return n || (n = new d(e), l.set(t, n)), n;
}
var d = class {
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,
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 = (e) => {
this.handleMessage(e.data);
}, this.ws.onerror = (e) => {
this.log("WebSocket error:", e);
let n = /* @__PURE__ */ Error("WebSocket connection error");
this.emit("error", n), t(n);
}, this.ws.onclose = (e) => {
this.log("WebSocket closed:", e.code, e.reason), this.stopHeartbeat(), this.setState("disconnected"), this.emit("disconnect", e), 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((e) => {
this.log("Reconnection failed:", e);
});
}, this.config.reconnectInterval));
};
} catch (e) {
t(e);
}
});
}
disconnect() {
this.isManualClose = !0, this.reconnectTimer &&= (clearTimeout(this.reconnectTimer), null), this.stopHeartbeat(), this.ws &&= (this.setState("disconnecting"), this.ws.close(), null), this.setState("disconnected"), this.messageHandlers.clear();
}
async request(t, n, r) {
this.ensureConnected();
let i = e(), a = {
id: i,
type: "request",
operation: t,
entity: n,
schema: r?.schema,
record_id: r?.record_id,
data: r?.data,
options: r?.options
};
return new Promise((e, t) => {
this.messageHandlers.set(i, (n) => {
n.success ? e(n.data) : t(Error(n.error?.message || "Request failed"));
}), this.send(a), setTimeout(() => {
this.messageHandlers.has(i) && (this.messageHandlers.delete(i), t(/* @__PURE__ */ 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, n) {
return this.request("create", e, {
schema: n?.schema,
data: t
});
}
async update(e, t, n, r) {
return this.request("update", e, {
schema: r?.schema,
record_id: t,
data: n
});
}
async delete(e, t, n) {
await this.request("delete", e, {
schema: n?.schema,
record_id: t
});
}
async meta(e, t) {
return this.request("meta", e, { schema: t?.schema });
}
async subscribe(t, n, r) {
this.ensureConnected();
let i = e(), a = {
id: i,
type: "subscription",
operation: "subscribe",
entity: t,
schema: r?.schema,
options: { filters: r?.filters }
};
return new Promise((e, o) => {
this.messageHandlers.set(i, (i) => {
if (i.success && i.data?.subscription_id) {
let a = i.data.subscription_id;
this.subscriptions.set(a, {
id: a,
entity: t,
schema: r?.schema,
options: { filters: r?.filters },
callback: n
}), this.log(`Subscribed to ${t} with ID: ${a}`), e(a);
} else o(Error(i.error?.message || "Subscription failed"));
}), this.send(a), setTimeout(() => {
this.messageHandlers.has(i) && (this.messageHandlers.delete(i), o(/* @__PURE__ */ Error("Subscription timeout")));
}, 1e4);
});
}
async unsubscribe(t) {
this.ensureConnected();
let n = e(), r = {
id: n,
type: "subscription",
operation: "unsubscribe",
subscription_id: t
};
return new Promise((e, i) => {
this.messageHandlers.set(n, (n) => {
n.success ? (this.subscriptions.delete(t), this.log(`Unsubscribed from ${t}`), e()) : i(Error(n.error?.message || "Unsubscribe failed"));
}), this.send(r), setTimeout(() => {
this.messageHandlers.has(n) && (this.messageHandlers.delete(n), i(/* @__PURE__ */ 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 {
let 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 (e) {
this.log("Error parsing message:", e);
}
}
handleResponse(e) {
let t = this.messageHandlers.get(e.id);
t && (t(e), this.messageHandlers.delete(e.id));
}
handleNotification(e) {
let t = this.subscriptions.get(e.subscription_id);
t?.callback && t.callback(e);
}
send(e) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw Error("WebSocket is not connected");
let t = JSON.stringify(e);
this.log("Sending message:", e), this.ws.send(t);
}
startHeartbeat() {
this.heartbeatTimer ||= setInterval(() => {
if (this.isConnected()) {
let t = {
id: e(),
type: "ping"
};
this.send(t);
}
}, this.config.heartbeatInterval);
}
stopHeartbeat() {
this.heartbeatTimer &&= (clearInterval(this.heartbeatTimer), null);
}
setState(e) {
this.state !== e && (this.state = e, this.emit("stateChange", e));
}
ensureConnected() {
if (!this.isConnected()) throw Error("WebSocket is not connected. Call connect() first.");
}
emit(e, ...t) {
let n = this.eventListeners[e];
n && n(...t);
}
log(...e) {
this.config.debug && console.log("[WebSocketClient]", ...e);
}
};
//#endregion
//#region src/headerspec/client.ts
function f(e) {
return "ZIP_" + n(e);
}
function p(e) {
let t = e;
return t.startsWith("ZIP_") ? (t = t.slice(4).replace(/[\n\r ]/g, ""), t = m(t)) : t.startsWith("__") && (t = t.slice(2).replace(/[\n\r ]/g, ""), t = m(t)), (t.startsWith("ZIP_") || t.startsWith("__")) && (t = p(t)), t;
}
function m(e) {
return t(e);
}
function h(e) {
let t = {};
if (e.columns?.length && (t["X-Select-Fields"] = e.columns.join(",")), e.omit_columns?.length && (t["X-Not-Select-Fields"] = e.omit_columns.join(",")), e.filters?.length) for (let n of e.filters) {
let e = n.logic_operator ?? "AND", r = g(n.operator), i = _(n);
n.operator === "eq" && e === "AND" ? t[`X-FieldFilter-${n.column}`] = i : e === "OR" ? t[`X-SearchOr-${r}-${n.column}`] = i : t[`X-SearchOp-${r}-${n.column}`] = i;
}
if (e.sort?.length && (t["X-Sort"] = e.sort.map((e) => e.direction.toUpperCase() === "DESC" ? `-${e.column}` : `+${e.column}`).join(",")), e.limit !== void 0 && (t["X-Limit"] = String(e.limit)), e.offset !== void 0 && (t["X-Offset"] = String(e.offset)), e.cursor_forward && (t["X-Cursor-Forward"] = e.cursor_forward), e.cursor_backward && (t["X-Cursor-Backward"] = e.cursor_backward), e.preload?.length && (t["X-Preload"] = e.preload.map((e) => e.columns?.length ? `${e.relation}:${e.columns.join(",")}` : e.relation).join("|")), e.fetch_row_number && (t["X-Fetch-RowNumber"] = e.fetch_row_number), e.computedColumns?.length) for (let n of e.computedColumns) t[`X-CQL-SEL-${n.name}`] = n.expression;
return e.customOperators?.length && (t["X-Custom-SQL-W"] = e.customOperators.map((e) => e.sql).join(" AND ")), t;
}
function g(e) {
switch (e) {
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 e;
}
}
function _(e) {
return e.value === null || e.value === void 0 ? "" : Array.isArray(e.value) ? e.value.join(",") : String(e.value);
}
var v = /* @__PURE__ */ new Map();
function y(e) {
let t = a(e), n = v.get(t);
return n || (n = new b(e), v.set(t, n)), n;
}
var b = class {
constructor(e) {
this.config = {
...e,
headers: { ...e.headers }
};
}
buildUrl(e, t, n) {
let r = `${this.config.baseUrl}/${e}/${t}`;
return n && (r += `/${n}`), r;
}
baseHeaders() {
return i(this.config);
}
async fetchWithError(e, t) {
let n = await fetch(e, t), r = await n.json();
if (!n.ok) throw Error(r.error?.message || `${n.statusText} (${n.status})`);
return {
data: r,
success: !0,
error: r.error ? r.error : void 0,
metadata: {
count: n.headers.get("content-range") ? Number(n.headers.get("content-range")?.split("/")[1]) : 0,
total: n.headers.get("content-range") ? Number(n.headers.get("content-range")?.split("/")[1]) : 0,
filtered: n.headers.get("content-range") ? Number(n.headers.get("content-range")?.split("/")[1]) : 0,
offset: n.headers.get("content-range") ? Number(n.headers.get("content-range")?.split("/")[0].split("-")[0]) : 0,
limit: n.headers.get("x-limit") ? Number(n.headers.get("x-limit")) : 0
}
};
}
async read(e, t, n, i) {
let a = this.buildUrl(e, t, n), o = i ? h(i) : {};
return this.fetchWithError(a, {
method: "GET",
headers: r(this.baseHeaders(), o)
});
}
async create(e, t, n, i) {
let a = this.buildUrl(e, t), o = i ? h(i) : {};
return this.fetchWithError(a, {
method: "POST",
headers: r(this.baseHeaders(), o),
body: JSON.stringify(n)
});
}
async update(e, t, n, i, a) {
let o = this.buildUrl(e, t, n), s = a ? h(a) : {};
return this.fetchWithError(o, {
method: "PUT",
headers: r(this.baseHeaders(), s),
body: JSON.stringify(i)
});
}
async delete(e, t, n) {
let r = this.buildUrl(e, t, n);
return this.fetchWithError(r, {
method: "DELETE",
headers: this.baseHeaders()
});
}
};
//#endregion
export { b as HeaderSpecClient, c as ResolveSpecClient, d as WebSocketClient, h as buildHeaders, p as decodeHeaderValue, f as encodeHeaderValue, y as getHeaderSpecClient, s as getResolveSpecClient, u as getWebSocketClient };
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@warkypublic/resolvespec-js",
"version": "1.0.2",
"description": "TypeScript client library for ResolveSpec REST, HeaderSpec, and WebSocket APIs",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "vite build",
"clean": "rm -rf dist",
"prepublishOnly": "npm run build",
"test": "vitest run",
"lint": "eslint src"
},
"keywords": [
"resolvespec",
"headerspec",
"websocket",
"rest-client",
"typescript",
"api-client"
],
"author": "Hein (Warkanum) Puth",
"license": "MIT",
"dependencies": {
"@warkypublic/artemis-kit": "^1.0.10",
"uuid": "^14.0.2"
},
"devDependencies": {
"@changesets/cli": "^3.0.3",
"@eslint/js": "^10.0.1",
"@types/jsdom": "^30.0.0",
"@types/node": "^26.6.2",
"eslint": "^10.11.0",
"globals": "^17.12.0",
"jsdom": "^30.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.0",
"vite-plugin-dts": "^5.1.1",
"vitest": "^5.0.1"
},
"engines": {
"node": ">=18"
},
"repository": {
"type": "git",
"url": "git+https://github.com/bitechdev/ResolveSpec"
},
"bugs": {
"url": "https://github.com/bitechdev/ResolveSpec/issues"
},
"homepage": "https://github.com/bitechdev/ResolveSpec#readme",
"packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
packages:
- '.'
allowBuilds:
esbuild: true
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import type {
Options,
FilterOption,
SortOption,
PreloadOption,
RequestBody,
APIResponse,
Metadata,
APIError,
Parameter,
ComputedColumn,
CustomOperator,
} from '../common/types';
describe('Common Types', () => {
it('should construct a valid FilterOption with logic_operator', () => {
const filter: FilterOption = {
column: 'name',
operator: 'eq',
value: 'test',
logic_operator: 'OR',
};
expect(filter.logic_operator).toBe('OR');
expect(filter.operator).toBe('eq');
});
it('should construct Options with all new fields', () => {
const opts: Options = {
columns: ['id', 'name'],
omit_columns: ['secret'],
filters: [{ column: 'age', operator: 'gte', value: 18 }],
sort: [{ column: 'name', direction: 'asc' }],
limit: 10,
offset: 0,
cursor_forward: 'abc123',
cursor_backward: 'xyz789',
fetch_row_number: '42',
parameters: [{ name: 'param1', value: 'val1', sequence: 1 }],
computedColumns: [{ name: 'full_name', expression: "first || ' ' || last" }],
customOperators: [{ name: 'custom', sql: "status = 'active'" }],
preload: [{
relation: 'Items',
columns: ['id', 'title'],
omit_columns: ['internal'],
sort: [{ column: 'id', direction: 'ASC' }],
recursive: true,
primary_key: 'id',
related_key: 'parent_id',
sql_joins: ['LEFT JOIN other ON other.id = items.other_id'],
join_aliases: ['other'],
}],
};
expect(opts.omit_columns).toEqual(['secret']);
expect(opts.cursor_forward).toBe('abc123');
expect(opts.fetch_row_number).toBe('42');
expect(opts.parameters![0].sequence).toBe(1);
expect(opts.preload![0].recursive).toBe(true);
});
it('should construct a RequestBody with numeric id', () => {
const body: RequestBody = {
operation: 'read',
id: 42,
options: { limit: 10 },
};
expect(body.id).toBe(42);
});
it('should construct a RequestBody with string array id', () => {
const body: RequestBody = {
operation: 'delete',
id: ['1', '2', '3'],
};
expect(Array.isArray(body.id)).toBe(true);
});
it('should construct Metadata with count and row_number', () => {
const meta: Metadata = {
total: 100,
count: 10,
filtered: 50,
limit: 10,
offset: 0,
row_number: 5,
};
expect(meta.count).toBe(10);
expect(meta.row_number).toBe(5);
});
it('should construct APIError with detail field', () => {
const err: APIError = {
code: 'not_found',
message: 'Record not found',
detail: 'The record with id 42 does not exist',
};
expect(err.detail).toBeDefined();
});
it('should construct APIResponse with metadata', () => {
const resp: APIResponse<string[]> = {
success: true,
data: ['a', 'b'],
metadata: { total: 2, count: 2, filtered: 2, limit: 10, offset: 0 },
};
expect(resp.metadata?.count).toBe(2);
});
it('should support all operator types', () => {
const operators: FilterOption['operator'][] = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
'like', 'ilike', 'in',
'contains', 'startswith', 'endswith',
'between', 'between_inclusive',
'is_null', 'is_not_null',
];
for (const op of operators) {
const f: FilterOption = { column: 'x', operator: op, value: 'v' };
expect(f.operator).toBe(op);
}
});
it('should support PreloadOption with computed_ql and where', () => {
const preload: PreloadOption = {
relation: 'Details',
where: "status = 'active'",
computed_ql: { cql1: 'SUM(amount)' },
table_name: 'detail_table',
updatable: true,
foreign_key: 'detail_id',
recursive_child_key: 'parent_detail_id',
};
expect(preload.computed_ql?.cql1).toBe('SUM(amount)');
expect(preload.updatable).toBe(true);
});
it('should support Parameter interface', () => {
const p: Parameter = { name: 'key', value: 'val' };
expect(p.name).toBe('key');
const p2: Parameter = { name: 'key2', value: 'val2', sequence: 5 };
expect(p2.sequence).toBe(5);
});
});
@@ -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');
});
});
}
@@ -0,0 +1,346 @@
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'] });
expect(h['X-Select-Fields']).toBe('id,name,email');
});
it('should set X-Not-Select-Fields for omit_columns', () => {
const h = buildHeaders({ omit_columns: ['secret', 'internal'] });
expect(h['X-Not-Select-Fields']).toBe('secret,internal');
});
it('should set X-FieldFilter for eq AND filters', () => {
const h = buildHeaders({
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
});
expect(h['X-FieldFilter-status']).toBe('active');
});
it('should set X-SearchOp for non-eq AND filters', () => {
const h = buildHeaders({
filters: [{ column: 'age', operator: 'gte', value: 18 }],
});
expect(h['X-SearchOp-greaterthanorequal-age']).toBe('18');
});
it('should set X-SearchOr for OR filters', () => {
const h = buildHeaders({
filters: [{ column: 'name', operator: 'contains', value: 'test', logic_operator: 'OR' }],
});
expect(h['X-SearchOr-contains-name']).toBe('test');
});
it('should set X-Sort with direction prefixes', () => {
const h = buildHeaders({
sort: [
{ column: 'name', direction: 'asc' },
{ column: 'created_at', direction: 'DESC' },
],
});
expect(h['X-Sort']).toBe('+name,-created_at');
});
it('should set X-Limit and X-Offset', () => {
const h = buildHeaders({ limit: 25, offset: 50 });
expect(h['X-Limit']).toBe('25');
expect(h['X-Offset']).toBe('50');
});
it('should set cursor pagination headers', () => {
const h = buildHeaders({ cursor_forward: 'abc', cursor_backward: 'xyz' });
expect(h['X-Cursor-Forward']).toBe('abc');
expect(h['X-Cursor-Backward']).toBe('xyz');
});
it('should set X-Preload with pipe-separated relations', () => {
const h = buildHeaders({
preload: [
{ relation: 'Items', columns: ['id', 'name'] },
{ relation: 'Category' },
],
});
expect(h['X-Preload']).toBe('Items:id,name|Category');
});
it('should set X-Fetch-RowNumber', () => {
const h = buildHeaders({ fetch_row_number: '42' });
expect(h['X-Fetch-RowNumber']).toBe('42');
});
it('should set X-CQL-SEL for computed columns', () => {
const h = buildHeaders({
computedColumns: [
{ name: 'total', expression: 'price * qty' },
],
});
expect(h['X-CQL-SEL-total']).toBe('price * qty');
});
it('should set X-Custom-SQL-W for custom operators', () => {
const h = buildHeaders({
customOperators: [
{ name: 'active', sql: "status = 'active'" },
{ name: 'verified', sql: "verified = true" },
],
});
expect(h['X-Custom-SQL-W']).toBe("status = 'active' AND verified = true");
});
it('should return empty object for empty options', () => {
const h = buildHeaders({});
expect(Object.keys(h)).toHaveLength(0);
});
it('should handle between filter with array value', () => {
const h = buildHeaders({
filters: [{ column: 'price', operator: 'between', value: [10, 100] }],
});
expect(h['X-SearchOp-between-price']).toBe('10,100');
});
it('should handle is_null filter with null value', () => {
const h = buildHeaders({
filters: [{ column: 'deleted_at', operator: 'is_null', value: null }],
});
expect(h['X-SearchOp-empty-deleted_at']).toBe('');
});
it('should handle in filter with array value', () => {
const h = buildHeaders({
filters: [{ column: 'id', operator: 'in', value: [1, 2, 3] }],
});
expect(h['X-SearchOp-in-id']).toBe('1,2,3');
});
});
describe('encodeHeaderValue / decodeHeaderValue', () => {
it('should round-trip encode/decode', () => {
const original = 'some complex value with spaces & symbols!';
const encoded = encodeHeaderValue(original);
expect(encoded.startsWith('ZIP_')).toBe(true);
const decoded = decodeHeaderValue(encoded);
expect(decoded).toBe(original);
});
it('should round-trip UTF-8 values', () => {
const original = 'café ☕ 你好';
expect(decodeHeaderValue(encodeHeaderValue(original))).toBe(original);
});
it('should decode __ prefixed values', () => {
const encoded = '__' + btoa('hello');
expect(decodeHeaderValue(encoded)).toBe('hello');
});
it('should decode UTF-8 values with the __ prefix', () => {
const bytes = new TextEncoder().encode('café ☕');
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
expect(decodeHeaderValue('__' + btoa(binary))).toBe('café ☕');
});
it('should return plain values as-is', () => {
expect(decodeHeaderValue('plain')).toBe('plain');
});
});
describe('HeaderSpecClient', () => {
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'tok' };
function mockFetch<T>(data: APIResponse<T>, ok = true) {
return vi.fn().mockResolvedValue({
ok,
headers: new Headers(),
json: () => Promise.resolve(data),
});
}
beforeEach(() => {
vi.restoreAllMocks();
});
it('read() sends GET with headers from options', async () => {
globalThis.fetch = mockFetch({ success: true, data: [{ id: 1 }] });
const client = new HeaderSpecClient(config);
await client.read('public', 'users', undefined, {
columns: ['id', 'name'],
limit: 10,
});
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users');
expect(opts.method).toBe('GET');
expect(opts.headers['X-Select-Fields']).toBe('id,name');
expect(opts.headers['X-Limit']).toBe('10');
expect(opts.headers['Authorization']).toBe('Bearer tok');
});
it('read() with id appends to URL', async () => {
globalThis.fetch = mockFetch({ success: true, data: {} });
const client = new HeaderSpecClient(config);
await client.read('public', 'users', '42');
const [url] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/42');
});
it('create() sends POST with body and headers', async () => {
globalThis.fetch = mockFetch({ success: true, data: { id: 1 } });
const client = new HeaderSpecClient(config);
await client.create('public', 'users', { name: 'Test' });
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(opts.method).toBe('POST');
expect(JSON.parse(opts.body)).toEqual({ name: 'Test' });
});
it('update() sends PUT with id in URL', async () => {
globalThis.fetch = mockFetch({ success: true, data: {} });
const client = new HeaderSpecClient(config);
await client.update('public', 'users', '1', { name: 'Updated' }, {
filters: [{ column: 'active', operator: 'eq', value: true }],
});
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
expect(opts.method).toBe('PUT');
expect(opts.headers['X-FieldFilter-active']).toBe('true');
});
it('delete() sends DELETE', async () => {
globalThis.fetch = mockFetch({ success: true, data: undefined as any });
const client = new HeaderSpecClient(config);
await client.delete('public', 'users', '1');
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
expect(opts.method).toBe('DELETE');
});
it('throws on non-ok response', async () => {
globalThis.fetch = mockFetch(
{ success: false, data: null as any, error: { code: 'err', message: 'fail' } },
false
);
const client = new HeaderSpecClient(config);
await expect(client.read('public', 'users')).rejects.toThrow('fail');
});
});
describe('getHeaderSpecClient singleton', () => {
it('returns same instance for same baseUrl', () => {
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
expect(a).toBe(b);
});
it('returns different instances for different baseUrls', () => {
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-a:3000' });
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-b:3000' });
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ResolveSpecClient, getResolveSpecClient } from '../resolvespec/client';
import type { ClientConfig, APIResponse } from '../common/types';
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'test-token' };
function mockFetchResponse<T>(data: APIResponse<T>, ok = true, status = 200) {
return vi.fn().mockResolvedValue({
ok,
status,
json: () => Promise.resolve(data),
});
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe('ResolveSpecClient', () => {
it('read() sends POST with operation read', async () => {
const response: APIResponse = { success: true, data: [{ id: 1 }] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
const result = await client.read('public', 'users', 1);
expect(result.success).toBe(true);
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
expect(opts.method).toBe('POST');
expect(opts.headers['Authorization']).toBe('Bearer test-token');
const body = JSON.parse(opts.body);
expect(body.operation).toBe('read');
});
it('read() with string array id puts id in body', async () => {
const response: APIResponse = { success: true, data: [] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.read('public', 'users', ['1', '2']);
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.id).toEqual(['1', '2']);
});
it('read() passes options through', async () => {
const response: APIResponse = { success: true, data: [] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.read('public', 'users', undefined, {
columns: ['id', 'name'],
omit_columns: ['secret'],
filters: [{ column: 'active', operator: 'eq', value: true }],
sort: [{ column: 'name', direction: 'asc' }],
limit: 10,
offset: 0,
cursor_forward: 'cursor1',
fetch_row_number: '5',
});
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.options.columns).toEqual(['id', 'name']);
expect(body.options.omit_columns).toEqual(['secret']);
expect(body.options.cursor_forward).toBe('cursor1');
expect(body.options.fetch_row_number).toBe('5');
});
it('create() sends POST with operation create and data', async () => {
const response: APIResponse = { success: true, data: { id: 1, name: 'Test' } };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
const result = await client.create('public', 'users', { name: 'Test' });
expect(result.data.name).toBe('Test');
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.operation).toBe('create');
expect(body.data.name).toBe('Test');
});
it('update() with single id puts id in URL', async () => {
const response: APIResponse = { success: true, data: { id: 1 } };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.update('public', 'users', { name: 'Updated' }, 1);
const [url] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
});
it('update() with string array id puts id in body', async () => {
const response: APIResponse = { success: true, data: {} };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.update('public', 'users', { active: false }, ['1', '2']);
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
expect(body.id).toEqual(['1', '2']);
});
it('delete() sends POST with operation delete', async () => {
const response: APIResponse<void> = { success: true, data: undefined as any };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
await client.delete('public', 'users', 1);
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
expect(url).toBe('http://localhost:3000/public/users/1');
const body = JSON.parse(opts.body);
expect(body.operation).toBe('delete');
});
it('getMetadata() sends GET request', async () => {
const response: APIResponse = {
success: true,
data: { schema: 'public', table: 'users', columns: [], relations: [] },
};
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(config);
const result = await client.getMetadata('public', 'users');
expect(result.data.table).toBe('users');
const opts = (globalThis.fetch as any).mock.calls[0][1];
expect(opts.method).toBe('GET');
});
it('throws on non-ok response', async () => {
const errorResp = {
success: false,
data: null,
error: { code: 'not_found', message: 'Not found' },
};
globalThis.fetch = mockFetchResponse(errorResp as any, false, 404);
const client = new ResolveSpecClient(config);
await expect(client.read('public', 'users', 999)).rejects.toThrow('Not found');
});
it('throws generic error when no error message', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ success: false, data: null }),
});
const client = new ResolveSpecClient(config);
await expect(client.read('public', 'users')).rejects.toThrow('An error occurred');
});
it('config without token omits Authorization header', async () => {
const noAuthConfig: ClientConfig = { baseUrl: 'http://localhost:3000' };
const response: APIResponse = { success: true, data: [] };
globalThis.fetch = mockFetchResponse(response);
const client = new ResolveSpecClient(noAuthConfig);
await client.read('public', 'users');
const opts = (globalThis.fetch as any).mock.calls[0][1];
expect(opts.headers['Authorization']).toBeUndefined();
});
});
describe('getResolveSpecClient singleton', () => {
it('returns same instance for same baseUrl', () => {
const a = getResolveSpecClient({ baseUrl: 'http://singleton-test:3000' });
const b = getResolveSpecClient({ baseUrl: 'http://singleton-test:3000' });
expect(a).toBe(b);
});
it('returns different instances for different baseUrls', () => {
const a = getResolveSpecClient({ baseUrl: 'http://singleton-a:3000' });
const b = getResolveSpecClient({ baseUrl: 'http://singleton-b:3000' });
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,336 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebSocketClient, getWebSocketClient } from '../websocketspec/client';
import type { WebSocketClientConfig } from '../websocketspec/types';
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid-1234'),
}));
// Mock WebSocket
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
url: string;
readyState = MockWebSocket.OPEN;
onopen: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onerror: ((ev: any) => void) | null = null;
private sentMessages: string[] = [];
constructor(url: string) {
this.url = url;
// Simulate async open
setTimeout(() => {
this.onopen?.({});
}, 0);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: 1000, reason: 'Normal closure' } as any);
}
getSentMessages(): any[] {
return this.sentMessages.map((m) => JSON.parse(m));
}
simulateMessage(data: any) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
let mockWsInstance: MockWebSocket | null = null;
beforeEach(() => {
mockWsInstance = null;
(globalThis as any).WebSocket = class extends MockWebSocket {
constructor(url: string) {
super(url);
mockWsInstance = this;
}
};
(globalThis as any).WebSocket.OPEN = MockWebSocket.OPEN;
(globalThis as any).WebSocket.CLOSED = MockWebSocket.CLOSED;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('WebSocketClient', () => {
const wsConfig: WebSocketClientConfig = {
url: 'ws://localhost:8080',
reconnect: false,
heartbeatInterval: 60000,
};
it('should connect and set state to connected', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
expect(client.getState()).toBe('connected');
expect(client.isConnected()).toBe(true);
client.disconnect();
});
it('should disconnect and set state to disconnected', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
client.disconnect();
expect(client.getState()).toBe('disconnected');
expect(client.isConnected()).toBe(false);
});
it('should send read request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const readPromise = client.read('users', {
schema: 'public',
filters: [{ column: 'active', operator: 'eq', value: true }],
limit: 10,
});
// Simulate server response
const sent = mockWsInstance!.getSentMessages();
expect(sent.length).toBe(1);
expect(sent[0].operation).toBe('read');
expect(sent[0].entity).toBe('users');
expect(sent[0].options.filters[0].column).toBe('active');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: [{ id: 1 }],
timestamp: new Date().toISOString(),
});
const result = await readPromise;
expect(result).toEqual([{ id: 1 }]);
client.disconnect();
});
it('should send create request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const createPromise = client.create('users', { name: 'Test' }, { schema: 'public' });
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].operation).toBe('create');
expect(sent[0].data.name).toBe('Test');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { id: 1, name: 'Test' },
timestamp: new Date().toISOString(),
});
const result = await createPromise;
expect(result.name).toBe('Test');
client.disconnect();
});
it('should send update request with record_id', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const updatePromise = client.update('users', '1', { name: 'Updated' });
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].operation).toBe('update');
expect(sent[0].record_id).toBe('1');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { id: 1, name: 'Updated' },
timestamp: new Date().toISOString(),
});
await updatePromise;
client.disconnect();
});
it('should send delete request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const deletePromise = client.delete('users', '1');
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].operation).toBe('delete');
expect(sent[0].record_id).toBe('1');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
timestamp: new Date().toISOString(),
});
await deletePromise;
client.disconnect();
});
it('should reject on failed request', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const readPromise = client.read('users');
const sent = mockWsInstance!.getSentMessages();
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: false,
error: { code: 'not_found', message: 'Not found' },
timestamp: new Date().toISOString(),
});
await expect(readPromise).rejects.toThrow('Not found');
client.disconnect();
});
it('should handle subscriptions', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
const callback = vi.fn();
const subPromise = client.subscribe('users', callback, {
schema: 'public',
});
const sent = mockWsInstance!.getSentMessages();
expect(sent[0].type).toBe('subscription');
expect(sent[0].operation).toBe('subscribe');
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { subscription_id: 'sub-1' },
timestamp: new Date().toISOString(),
});
const subId = await subPromise;
expect(subId).toBe('sub-1');
expect(client.getSubscriptions()).toHaveLength(1);
// Simulate notification
mockWsInstance!.simulateMessage({
type: 'notification',
operation: 'create',
subscription_id: 'sub-1',
entity: 'users',
data: { id: 2, name: 'New' },
timestamp: new Date().toISOString(),
});
expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.calls[0][0].data.id).toBe(2);
client.disconnect();
});
it('should handle unsubscribe', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
// Subscribe first
const subPromise = client.subscribe('users', vi.fn());
let sent = mockWsInstance!.getSentMessages();
mockWsInstance!.simulateMessage({
id: sent[0].id,
type: 'response',
success: true,
data: { subscription_id: 'sub-1' },
timestamp: new Date().toISOString(),
});
await subPromise;
// Unsubscribe
const unsubPromise = client.unsubscribe('sub-1');
sent = mockWsInstance!.getSentMessages();
mockWsInstance!.simulateMessage({
id: sent[sent.length - 1].id,
type: 'response',
success: true,
timestamp: new Date().toISOString(),
});
await unsubPromise;
expect(client.getSubscriptions()).toHaveLength(0);
client.disconnect();
});
it('should emit events', async () => {
const client = new WebSocketClient(wsConfig);
const connectCb = vi.fn();
const stateChangeCb = vi.fn();
client.on('connect', connectCb);
client.on('stateChange', stateChangeCb);
await client.connect();
expect(connectCb).toHaveBeenCalledTimes(1);
expect(stateChangeCb).toHaveBeenCalled();
client.off('connect');
client.disconnect();
});
it('should reject when sending without connection', async () => {
const client = new WebSocketClient(wsConfig);
await expect(client.read('users')).rejects.toThrow('WebSocket is not connected');
});
it('should handle pong messages without error', async () => {
const client = new WebSocketClient(wsConfig);
await client.connect();
// Should not throw
mockWsInstance!.simulateMessage({ type: 'pong' });
client.disconnect();
});
it('should handle malformed messages gracefully', async () => {
const client = new WebSocketClient({ ...wsConfig, debug: false });
await client.connect();
// Simulate non-JSON message
mockWsInstance!.onmessage?.({ data: 'not-json' } as any);
client.disconnect();
});
});
describe('getWebSocketClient singleton', () => {
it('returns same instance for same url', () => {
const a = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
const b = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
expect(a).toBe(b);
});
it('returns different instances for different urls', () => {
const a = getWebSocketClient({ url: 'ws://ws-singleton-a:8080' });
const b = getWebSocketClient({ url: 'ws://ws-singleton-b:8080' });
expect(a).not.toBe(b);
});
});
+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]);
}
@@ -0,0 +1 @@
export * from './types';
+216
View File
@@ -0,0 +1,216 @@
// Types aligned with Go pkg/common/types.go
export type Operator =
| 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
| 'like' | 'ilike' | 'in'
| 'contains' | 'startswith' | 'endswith'
| 'between' | 'between_inclusive'
| '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';
export interface Parameter {
name: string;
value: string;
sequence?: number;
}
export interface PreloadOption {
relation: string;
table_name?: string;
columns?: string[];
omit_columns?: string[];
sort?: SortOption[];
filters?: FilterOption[];
where?: string;
limit?: number;
offset?: number;
updatable?: boolean;
computed_ql?: Record<string, string>;
recursive?: boolean;
// Relationship keys
primary_key?: string;
related_key?: string;
foreign_key?: string;
recursive_child_key?: string;
// Custom SQL JOINs
sql_joins?: string[];
join_aliases?: string[];
}
export interface FilterOption {
column: string;
operator: Operator | string;
value: any;
logic_operator?: 'AND' | 'OR';
}
export interface SortOption {
column: string;
direction: SortDirection;
}
export interface CustomOperator {
name: string;
sql: string;
}
export interface ComputedColumn {
name: string;
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[];
omit_columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
parameters?: Parameter[];
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 {
operation: Operation;
id?: number | string | string[];
data?: any | any[];
options?: Options;
}
export interface Metadata {
total: number;
count: number;
filtered: number;
limit: number;
offset: number;
row_number?: number;
}
export interface APIError {
code: string;
message: string;
details?: any;
detail?: string;
}
export interface APIResponse<T = any> {
success: boolean;
data: T;
metadata?: Metadata;
error?: APIError;
}
export interface Column {
name: string;
type: string;
is_nullable: boolean;
is_primary: boolean;
is_unique: boolean;
has_index: boolean;
}
export interface TableMetadata {
schema: string;
table: string;
columns: Column[];
relations: string[];
}
export interface ClientConfig {
baseUrl: string;
token?: string;
/** Custom HTTP headers. Token and HeaderSpec query options take precedence. */
headers?: Record<string, string>;
}
@@ -0,0 +1,445 @@
import { clientCacheKey, clientHeaders, mergeHeaders } from '../common/http';
import { b64DecodeUnicode, b64EncodeUnicode } from '@warkypublic/artemis-kit/base64';
import type {
APIResponse,
ClientConfig,
CustomOperator,
FilterOption,
HeaderSpecOptions,
PreloadOption,
SortOption,
} from "../common/types";
/**
* Encode a value with base64 and ZIP_ prefix for complex header values.
*/
export function encodeHeaderValue(value: string): string {
return "ZIP_" + b64EncodeUnicode(value);
}
/**
* Decode a header value that may be base64 encoded with ZIP_ or __ prefix.
*/
export function decodeHeaderValue(value: string): string {
let code = value;
if (code.startsWith("ZIP_")) {
code = code.slice(4).replace(/[\n\r ]/g, "");
code = decodeBase64(code);
} else if (code.startsWith("__")) {
code = code.slice(2).replace(/[\n\r ]/g, "");
code = decodeBase64(code);
}
// Handle nested encoding
if (code.startsWith("ZIP_") || code.startsWith("__")) {
code = decodeHeaderValue(code);
}
return code;
}
function decodeBase64(str: string): string {
return b64DecodeUnicode(str);
}
/**
* Build HTTP headers from Options, matching Go's restheadspec handler conventions.
*
* Header mapping:
* - X-Select-Fields: comma-separated columns
* - X-Not-Select-Fields: comma-separated omit_columns
* - X-FieldFilter-{col}: exact match (eq)
* - X-SearchOp-{operator}-{col}: AND filter
* - X-SearchOr-{operator}-{col}: OR filter
* - X-Sort: +col (asc), -col (desc)
* - X-Limit, X-Offset: pagination
* - X-Cursor-Forward, X-Cursor-Backward: cursor pagination
* - X-Preload: RelationName:field1,field2 pipe-separated
* - 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: HeaderSpecOptions): Record<string, string> {
const headers: Record<string, string> = {};
// Column selection
if (options.columns?.length) {
headers["X-Select-Fields"] = options.columns.join(",");
}
if (options.omit_columns?.length) {
headers["X-Not-Select-Fields"] = options.omit_columns.join(",");
}
// Filters
if (options.filters?.length) {
for (const filter of options.filters) {
const logicOp = filter.logic_operator ?? "AND";
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;
} else if (logicOp === "OR") {
headers[`X-SearchOr-${op}-${filter.column}`] = valueStr;
} else {
headers[`X-SearchOp-${op}-${filter.column}`] = valueStr;
}
}
}
// Sort
if (options.sort?.length) {
const sortParts = options.sort.map((s: SortOption) => {
const dir = s.direction.toUpperCase();
return dir === "DESC" ? `-${s.column}` : `+${s.column}`;
});
headers["X-Sort"] = sortParts.join(",");
}
// Pagination
if (options.limit !== undefined) {
headers["X-Limit"] = String(options.limit);
}
if (options.offset !== undefined) {
headers["X-Offset"] = String(options.offset);
}
// Cursor pagination
if (options.cursor_forward) {
headers["X-Cursor-Forward"] = options.cursor_forward;
}
if (options.cursor_backward) {
headers["X-Cursor-Backward"] = options.cursor_backward;
}
// Preload
if (options.preload?.length) {
// 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;
}
}
}
// 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
if (options.fetch_row_number) {
headers["X-Fetch-RowNumber"] = options.fetch_row_number;
}
// Computed columns
if (options.computedColumns?.length) {
for (const cc of options.computedColumns) {
headers[`X-CQL-SEL-${cc.name}`] = cc.expression;
}
}
// Custom operators -> X-Custom-SQL-W
if (options.customOperators?.length) {
const sqlParts = options.customOperators.map(
(co: CustomOperator) => co.sql,
);
headers["X-Custom-SQL-W"] = sqlParts.join(" AND ");
}
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":
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 operator;
}
}
function formatFilterValue(filter: FilterOption): string {
if (filter.value === null || filter.value === undefined) {
return "";
}
if (Array.isArray(filter.value)) {
return filter.value.join(",");
}
return String(filter.value);
}
const instances = new Map<string, HeaderSpecClient>();
export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient {
const key = clientCacheKey(config);
let instance = instances.get(key);
if (!instance) {
instance = new HeaderSpecClient(config);
instances.set(key, instance);
}
return instance;
}
/**
* HeaderSpec REST client.
* Sends query options via HTTP headers instead of request body, matching the Go restheadspec handler.
*
* HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
*/
export class HeaderSpecClient {
private config: ClientConfig;
constructor(config: ClientConfig) {
this.config = { ...config, headers: { ...config.headers } };
}
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> {
return clientHeaders(this.config);
}
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})`,
);
}
return {
data: data,
success: true,
error: data.error ? data.error : undefined,
metadata: {
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,
},
};
}
async read<T = any>(
schema: string,
entity: string,
id?: string,
options?: HeaderSpecOptions,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity, id);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "GET",
headers: mergeHeaders(this.baseHeaders(), optHeaders),
});
}
async create<T = any>(
schema: string,
entity: string,
data: any,
options?: HeaderSpecOptions,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "POST",
headers: mergeHeaders(this.baseHeaders(), optHeaders),
body: JSON.stringify(data),
});
}
async update<T = any>(
schema: string,
entity: string,
id: string,
data: any,
options?: HeaderSpecOptions,
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity, id);
const optHeaders = options ? buildHeaders(options) : {};
return this.fetchWithError<T>(url, {
method: "PUT",
headers: mergeHeaders(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(),
});
}
}
@@ -0,0 +1,7 @@
export {
HeaderSpecClient,
getHeaderSpecClient,
buildHeaders,
encodeHeaderValue,
decodeHeaderValue,
} from './client';
+11
View File
@@ -0,0 +1,11 @@
// Common types
export * from './common';
// REST client (ResolveSpec)
export * from './resolvespec';
// WebSocket client
export * from './websocketspec';
// HeaderSpec client
export * from './headerspec';
@@ -0,0 +1,134 @@
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 = clientCacheKey(config);
let instance = instances.get(key);
if (!instance) {
instance = new ResolveSpecClient(config);
instances.set(key, instance);
}
return instance;
}
export class ResolveSpecClient {
private config: ClientConfig;
constructor(config: ClientConfig) {
this.config = { ...config, headers: { ...config.headers } };
}
private buildUrl(schema: string, entity: string, id?: string): string {
let url = `${this.config.baseUrl}/${schema}/${entity}`;
if (id) {
url += `/${id}`;
}
return url;
}
private baseHeaders(): HeadersInit {
return clientHeaders(this.config);
}
private async fetchWithError<T>(url: string, options: RequestInit): Promise<APIResponse<T>> {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message || 'An error occurred');
}
return data;
}
async getMetadata(schema: string, entity: string): Promise<APIResponse<TableMetadata>> {
const url = this.buildUrl(schema, entity);
return this.fetchWithError<TableMetadata>(url, {
method: 'GET',
headers: this.baseHeaders(),
});
}
async read<T = any>(
schema: string,
entity: string,
id?: number | string | string[],
options?: Options
): Promise<APIResponse<T>> {
const urlId = typeof id === 'number' || typeof id === 'string' ? String(id) : undefined;
const url = this.buildUrl(schema, entity, urlId);
const body: RequestBody = {
operation: 'read',
id: Array.isArray(id) ? id : undefined,
options,
};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
async create<T = any>(
schema: string,
entity: string,
data: any | any[],
options?: Options
): Promise<APIResponse<T>> {
const url = this.buildUrl(schema, entity);
const body: RequestBody = {
operation: 'create',
data,
options,
};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
async update<T = any>(
schema: string,
entity: string,
data: any | any[],
id?: number | string | string[],
options?: Options
): Promise<APIResponse<T>> {
const urlId = typeof id === 'number' || typeof id === 'string' ? String(id) : undefined;
const url = this.buildUrl(schema, entity, urlId);
const body: RequestBody = {
operation: 'update',
id: Array.isArray(id) ? id : undefined,
data,
options,
};
return this.fetchWithError<T>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
async delete(
schema: string,
entity: string,
id: number | string
): Promise<APIResponse<void>> {
const url = this.buildUrl(schema, entity, String(id));
const body: RequestBody = {
operation: 'delete',
};
return this.fetchWithError<void>(url, {
method: 'POST',
headers: this.baseHeaders(),
body: JSON.stringify(body),
});
}
}
@@ -0,0 +1 @@
export { ResolveSpecClient, getResolveSpecClient } from './client';
@@ -0,0 +1,445 @@
import { v4 as uuidv4 } from 'uuid';
import type {
WebSocketClientConfig,
WSMessage,
WSRequestMessage,
WSResponseMessage,
WSNotificationMessage,
WSOperation,
WSOptions,
Subscription,
ConnectionState,
WebSocketClientEvents
} from './types';
import type { FilterOption, SortOption, PreloadOption } from '../common/types';
const instances = new Map<string, WebSocketClient>();
export function getWebSocketClient(config: WebSocketClientConfig): WebSocketClient {
const key = config.url;
let instance = instances.get(key);
if (!instance) {
instance = new WebSocketClient(config);
instances.set(key, instance);
}
return instance;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private config: Required<WebSocketClientConfig>;
private messageHandlers: Map<string, (message: WSResponseMessage) => void> = new Map();
private subscriptions: Map<string, Subscription> = new Map();
private eventListeners: Partial<WebSocketClientEvents> = {};
private state: ConnectionState = 'disconnected';
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private isManualClose = false;
constructor(config: WebSocketClientConfig) {
this.config = {
url: config.url,
reconnect: config.reconnect ?? true,
reconnectInterval: config.reconnectInterval ?? 3000,
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
heartbeatInterval: config.heartbeatInterval ?? 30000,
debug: config.debug ?? false
};
}
async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) {
this.log('Already connected');
return;
}
this.isManualClose = false;
this.setState('connecting');
return new Promise((resolve, reject) => {
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');
resolve();
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onerror = (event) => {
this.log('WebSocket error:', event);
const error = new Error('WebSocket connection error');
this.emit('error', error);
reject(error);
};
this.ws.onclose = (event) => {
this.log('WebSocket closed:', event.code, event.reason);
this.stopHeartbeat();
this.setState('disconnected');
this.emit('disconnect', event);
if (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((err) => {
this.log('Reconnection failed:', err);
});
}, this.config.reconnectInterval);
}
};
} catch (error) {
reject(error);
}
});
}
disconnect(): void {
this.isManualClose = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
if (this.ws) {
this.setState('disconnecting');
this.ws.close();
this.ws = null;
}
this.setState('disconnected');
this.messageHandlers.clear();
}
async request<T = any>(
operation: WSOperation,
entity: string,
options?: {
schema?: string;
record_id?: string;
data?: any;
options?: WSOptions;
}
): Promise<T> {
this.ensureConnected();
const id = uuidv4();
const message: WSRequestMessage = {
id,
type: 'request',
operation,
entity,
schema: options?.schema,
record_id: options?.record_id,
data: options?.data,
options: options?.options
};
return new Promise((resolve, reject) => {
this.messageHandlers.set(id, (response: WSResponseMessage) => {
if (response.success) {
resolve(response.data);
} else {
reject(new Error(response.error?.message || 'Request failed'));
}
});
this.send(message);
setTimeout(() => {
if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
async read<T = any>(entity: string, options?: {
schema?: string;
record_id?: string;
filters?: FilterOption[];
columns?: string[];
sort?: SortOption[];
preload?: PreloadOption[];
limit?: number;
offset?: number;
}): Promise<T> {
return this.request<T>('read', entity, {
schema: options?.schema,
record_id: options?.record_id,
options: {
filters: options?.filters,
columns: options?.columns,
sort: options?.sort,
preload: options?.preload,
limit: options?.limit,
offset: options?.offset
}
});
}
async create<T = any>(entity: string, data: any, options?: {
schema?: string;
}): Promise<T> {
return this.request<T>('create', entity, {
schema: options?.schema,
data
});
}
async update<T = any>(entity: string, id: string, data: any, options?: {
schema?: string;
}): Promise<T> {
return this.request<T>('update', entity, {
schema: options?.schema,
record_id: id,
data
});
}
async delete(entity: string, id: string, options?: {
schema?: string;
}): Promise<void> {
await this.request('delete', entity, {
schema: options?.schema,
record_id: id
});
}
async meta<T = any>(entity: string, options?: {
schema?: string;
}): Promise<T> {
return this.request<T>('meta', entity, {
schema: options?.schema
});
}
async subscribe(
entity: string,
callback: (notification: WSNotificationMessage) => void,
options?: {
schema?: string;
filters?: FilterOption[];
}
): Promise<string> {
this.ensureConnected();
const id = uuidv4();
const message: WSMessage = {
id,
type: 'subscription',
operation: 'subscribe',
entity,
schema: options?.schema,
options: {
filters: options?.filters
}
};
return new Promise((resolve, reject) => {
this.messageHandlers.set(id, (response: WSResponseMessage) => {
if (response.success && response.data?.subscription_id) {
const subscriptionId = response.data.subscription_id;
this.subscriptions.set(subscriptionId, {
id: subscriptionId,
entity,
schema: options?.schema,
options: { filters: options?.filters },
callback
});
this.log(`Subscribed to ${entity} with ID: ${subscriptionId}`);
resolve(subscriptionId);
} else {
reject(new Error(response.error?.message || 'Subscription failed'));
}
});
this.send(message);
setTimeout(() => {
if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id);
reject(new Error('Subscription timeout'));
}
}, 10000);
});
}
async unsubscribe(subscriptionId: string): Promise<void> {
this.ensureConnected();
const id = uuidv4();
const message: WSMessage = {
id,
type: 'subscription',
operation: 'unsubscribe',
subscription_id: subscriptionId
};
return new Promise((resolve, reject) => {
this.messageHandlers.set(id, (response: WSResponseMessage) => {
if (response.success) {
this.subscriptions.delete(subscriptionId);
this.log(`Unsubscribed from ${subscriptionId}`);
resolve();
} else {
reject(new Error(response.error?.message || 'Unsubscribe failed'));
}
});
this.send(message);
setTimeout(() => {
if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id);
reject(new Error('Unsubscribe timeout'));
}
}, 10000);
});
}
getSubscriptions(): Subscription[] {
return Array.from(this.subscriptions.values());
}
getState(): ConnectionState {
return this.state;
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void {
this.eventListeners[event] = callback as any;
}
off<K extends keyof WebSocketClientEvents>(event: K): void {
delete this.eventListeners[event];
}
// Private methods
private handleMessage(data: string): void {
try {
const message: WSMessage = JSON.parse(data);
this.log('Received message:', message);
this.emit('message', message);
switch (message.type) {
case 'response':
this.handleResponse(message as WSResponseMessage);
break;
case 'notification':
this.handleNotification(message as WSNotificationMessage);
break;
case 'pong':
break;
default:
this.log('Unknown message type:', message.type);
}
} catch (error) {
this.log('Error parsing message:', error);
}
}
private handleResponse(message: WSResponseMessage): void {
const handler = this.messageHandlers.get(message.id);
if (handler) {
handler(message);
this.messageHandlers.delete(message.id);
}
}
private handleNotification(message: WSNotificationMessage): void {
const subscription = this.subscriptions.get(message.subscription_id);
if (subscription?.callback) {
subscription.callback(message);
}
}
private send(message: WSMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
const data = JSON.stringify(message);
this.log('Sending message:', message);
this.ws.send(data);
}
private startHeartbeat(): void {
if (this.heartbeatTimer) {
return;
}
this.heartbeatTimer = setInterval(() => {
if (this.isConnected()) {
const pingMessage: WSMessage = {
id: uuidv4(),
type: 'ping'
};
this.send(pingMessage);
}
}, this.config.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private setState(state: ConnectionState): void {
if (this.state !== state) {
this.state = state;
this.emit('stateChange', state);
}
}
private ensureConnected(): void {
if (!this.isConnected()) {
throw new Error('WebSocket is not connected. Call connect() first.');
}
}
private emit<K extends keyof WebSocketClientEvents>(
event: K,
...args: Parameters<WebSocketClientEvents[K]>
): void {
const listener = this.eventListeners[event];
if (listener) {
(listener as any)(...args);
}
}
private log(...args: any[]): void {
if (this.config.debug) {
console.log('[WebSocketClient]', ...args);
}
}
}
export default WebSocketClient;
@@ -0,0 +1,2 @@
export * from './types';
export { WebSocketClient, getWebSocketClient } from './client';
@@ -0,0 +1,117 @@
import type { FilterOption, SortOption, PreloadOption, Parameter } from '../common/types';
// Re-export common types
export type { FilterOption, SortOption, PreloadOption, Operator, SortDirection } from '../common/types';
// WebSocket Message Types
export type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
export type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
export interface WSOptions {
filters?: FilterOption[];
columns?: string[];
omit_columns?: string[];
preload?: PreloadOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
export interface WSMessage {
id?: string;
type: MessageType;
operation?: WSOperation;
schema?: string;
entity?: string;
record_id?: string;
data?: any;
options?: WSOptions;
subscription_id?: string;
success?: boolean;
error?: WSErrorInfo;
metadata?: Record<string, any>;
timestamp?: string;
}
export interface WSErrorInfo {
code: string;
message: string;
details?: Record<string, any>;
}
export interface WSRequestMessage {
id: string;
type: 'request';
operation: WSOperation;
schema?: string;
entity: string;
record_id?: string;
data?: any;
options?: WSOptions;
}
export interface WSResponseMessage {
id: string;
type: 'response';
success: boolean;
data?: any;
error?: WSErrorInfo;
metadata?: Record<string, any>;
timestamp: string;
}
export interface WSNotificationMessage {
type: 'notification';
operation: WSOperation;
subscription_id: string;
schema?: string;
entity: string;
data: any;
timestamp: string;
}
export interface WSSubscriptionMessage {
id: string;
type: 'subscription';
operation: 'subscribe' | 'unsubscribe';
schema?: string;
entity: string;
options?: WSOptions;
subscription_id?: string;
}
export interface SubscriptionOptions {
filters?: FilterOption[];
onNotification?: (notification: WSNotificationMessage) => void;
}
export interface WebSocketClientConfig {
url: string;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
debug?: boolean;
}
export interface Subscription {
id: string;
entity: string;
schema?: string;
options?: WSOptions;
callback?: (notification: WSNotificationMessage) => void;
}
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'reconnecting';
export interface WebSocketClientEvents {
connect: () => void;
disconnect: (event: CloseEvent) => void;
error: (error: Error) => void;
message: (message: WSMessage) => void;
stateChange: (state: ConnectionState) => void;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src/__tests__"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [
dts({ rollupTypes: true }),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'ResolveSpec',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: ['uuid', 'semver', '@warkypublic/artemis-kit/base64'],
},
},
});
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.egg-info/
.venv/
dist/
.pytest_cache/
.coverage
+142
View File
@@ -0,0 +1,142 @@
# resolvespec (Python)
Python client for ResolveSpec REST, HeaderSpec (restheadspec), FunctionSpec and WebSocketSpec. Port of `resolvespec-js`.
- Python >= 3.11, `httpx` (REST, sync + async), `websockets` (WS, async)
- Options/filters/sorts are plain dicts using the wire key names (`TypedDict` hints in `resolvespec.types`)
```
pip install resolvespec
```
## Clients
| Protocol | Sync | Async | Transport |
|---|---|---|---|
| ResolveSpec | `ResolveSpecClient` | `AsyncResolveSpecClient` | POST + JSON body `{operation, id, data, options}` |
| HeaderSpec | `HeaderSpecClient` | `AsyncHeaderSpecClient` | GET/POST/PUT/DELETE, options as `X-*` headers |
| FunctionSpec | `FuncSpecClient` | `AsyncFuncSpecClient` | user-defined SQL endpoints; params via query string + `X-*` headers |
| WebSocketSpec | - | `WebSocketClient` | WebSocket JSON messages |
Constructor (REST): `Client(base_url, token=None, headers=None, timeout=30.0)`
- `token` -> `Authorization: Bearer`; wins over `headers`
- `headers`: custom headers, merged case-insensitively; snapshot at construction
- Sync: context manager / `close()`. Async: `async with` / `await aclose()`
- Cached sync factories: `get_resolvespec_client()`, `get_headerspec_client()` (same args -> same instance)
## ResolveSpec
URL: `{base}/{schema}/{entity}[/{id}]`
| Method | Signature |
|---|---|
| `get_metadata` | `(schema, entity)` (GET) |
| `read` | `(schema, entity, id=None, options=None)` |
| `create` | `(schema, entity, data, options=None)` |
| `update` | `(schema, entity, data, id=None, options=None)` |
| `delete` | `(schema, entity, id)` |
`id`: int/str -> URL path; `list[str]` -> body `id`.
Returns `{"success", "data", "metadata"?, "error"?}`.
## HeaderSpec
| Method | HTTP | Signature |
|---|---|---|
| `read` | GET | `(schema, entity, id=None, options=None)` |
| `create` | POST | `(schema, entity, data, options=None)` |
| `update` | PUT | `(schema, entity, id, data, options=None)` |
| `delete` | DELETE | `(schema, entity, id)` |
Response metadata derived from `Content-Range` (`offset-end/total`) and `X-Limit`.
`build_headers(options)`, `encode_header_value()` / `decode_header_value()` (`ZIP_` / `__` base64) are exported.
### Option -> header
| Option | Header |
|---|---|
| `columns` / `omit_columns` | `X-Select-Fields` / `X-Not-Select-Fields` |
| filter `eq` + AND | `X-FieldFilter-{col}` |
| filter AND / OR | `X-SearchOp-{op}-{col}` / `X-SearchOr-{op}-{col}` |
| spatial (`st_*`, `bbox`) / vector (`*_within`) filter | `X-SpatialFilter-{col}` / `X-VectorFilter-{col}` (JSON) |
| `sort` | `X-Sort` (`+col,-col`) |
| `limit` / `offset` | `X-Limit` / `X-Offset` |
| `cursor_forward` / `cursor_backward` | `X-Cursor-Forward` / `X-Cursor-Backward` |
| `preload` | `X-Preload` (`Rel:c1,c2\|Rel2`), `X-Preload-Where`, `X-Preload-{n}[-Where]` |
| `expand` | `X-Expand` |
| `custom_sql_joins` / `custom_sql_or` | `X-Custom-SQL-Join` / `X-Custom-SQL-Or` |
| `search_columns` | `X-SearchCols` |
| `advanced_sql` | `X-AdvSQL-{col}` |
| `computedColumns` | `X-CQL-SEL-{name}` |
| `customOperators` | `X-Custom-SQL-W` (AND-joined) |
| `vector_search` | `X-Vector-Search-{col}`, `-Vector`, `-As`, `-Dir` |
| `fetch_row_number` | `X-Fetch-RowNumber` |
| `clean_json`, `distinct`, `skip_count`, `skip_cache`, `atomic_transaction`, `single_record_as_object` | `X-Clean-JSON`, `X-Distinct`, `X-SkipCount`, `X-SkipCache`, `X-Transaction-Atomic`, `X-Single-Record-As-Object` |
| `pk_row` | `X-PKRow` |
| `response_format` (`simple`/`detail`/`syncfusion`) | `X-SimpleApi` / `X-DetailApi` / `X-Syncfusion` |
| `xfiles` | `X-Files` (`ZIP_` base64 JSON) |
Filter operator -> header op: `eq equals`, `neq notequals`, `gt greaterthan`, `gte greaterthanorequal`, `lt lessthan`, `lte lessthanorequal`, `like/ilike/contains contains`, `startswith beginswith`, `endswith`, `in`, `between`, `between_inclusive betweeninclusive`, `is_null empty`, `is_not_null notempty`.
## FunctionSpec
Routes are defined by the server app, so calls take a `path`. The server never reads a request body.
| Method | Server handler | Result |
|---|---|---|
| `query(path, params=None, options=None, *, method="GET")` | `SqlQuery` (single record) | `{success, data}` |
| `query_list(path, params=None, options=None, *, method="GET")` | `SqlQueryList` | `{success, data, metadata}` (from `Content-Range: items a-b/total`) |
- `params` -> query string. `bool` -> `true/false`, `None` skipped, `list` -> repeated key (server: `IN` filter). `p-` prefixed names are substituted into the SQL.
- `options` -> `X-*` headers. Query values override headers of the same name.
- 206 Partial Content (more rows than returned) is treated as success.
| Option | Header |
|---|---|
| `filters` (`eq`+AND) | `X-FieldFilter-{col}` |
| `filters` (other) | `X-SearchOp-{op}-{col}` / `X-SearchOr-{op}-{col}` |
| `search_filters` `{col: text}` | `X-SearchFilter-{col}` (ILIKE) |
| `custom_sql_where` / `custom_sql_or` | `X-Custom-SQL-W` / `X-Custom-SQL-Or` |
| `sort` | `X-Sort` as SQL terms: `col ASC,col DESC` |
| `limit` / `offset` | `X-Limit` / `X-Offset` |
| `distinct`, `skip_count`, `skip_cache` | `X-Distinct`, `X-SkipCount`, `X-SkipCache` |
| `response_format` | `X-SimpleApi` / `X-DetailApi` / `X-Syncfusion` (`data` shape changes: array / `{items,...}` / `{result,count}`) |
Server limits:
- `sort` goes verbatim into `ORDER BY`; `-col` (restheadspec style) does **not** mean DESC.
- `X-Select-Fields` / `X-Not-Select-Fields` are no-ops server-side, so not exposed.
- One search operator per column; same column twice keeps the last.
- Values starting with `ZIP_` / `__` are base64-decoded by the server; such plaintext cannot be sent.
- Non-ASCII / control-char values are sent `ZIP_`-encoded automatically.
## WebSocketSpec
`WebSocketClient(url, *, reconnect=True, reconnect_interval=3.0, max_reconnect_attempts=10, heartbeat_interval=30.0, request_timeout=30.0, subscribe_timeout=10.0, headers=None)`
| Method | Notes |
|---|---|
| `connect()` / `close()` | also `async with` |
| `request(operation, entity, *, schema, record_id, data, options)` | returns response `data` |
| `read(entity, *, schema, record_id, filters, columns, sort, preload, limit, offset)` | |
| `create(entity, data, *, schema)` | |
| `update(entity, id, data, *, schema)` | |
| `delete(entity, id, *, schema)` | |
| `meta(entity, *, schema)` | |
| `subscribe(entity, callback, *, schema, filters)` | returns subscription id; callback gets notification dict (sync or async) |
| `unsubscribe(subscription_id)` | |
| `on(event, cb)` / `off(event)` | events: `connect`, `disconnect`, `error`, `message`, `state_change` |
| `state`, `is_connected()`, `get_subscriptions()` | |
Auto-reconnect does not restore subscriptions; re-subscribe on `connect`.
## Errors
`ResolveSpecError(message, status_code, code, details)` on non-2xx (REST) or failed response / timeout / not connected (WS).
## Dev
```
pip install -e '.[dev]'
pytest
```
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "resolvespec"
version = "1.0.0"
description = "Python client for ResolveSpec REST, HeaderSpec and WebSocket APIs"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Hein (Warkanum) Puth" }]
keywords = ["resolvespec", "headerspec", "websocket", "rest-client", "api-client"]
dependencies = ["httpx>=0.27", "websockets>=13"]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-cov"]
[tool.hatch.build.targets.wheel]
packages = ["src/resolvespec"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
@@ -0,0 +1,43 @@
"""ResolveSpec Python client: REST (ResolveSpec), HeaderSpec and WebSocketSpec."""
from typing import Mapping, Optional
from .headerspec import (
AsyncHeaderSpecClient,
HeaderSpecClient,
build_headers,
decode_header_value,
encode_header_value,
)
from .funcspec import AsyncFuncSpecClient, FuncSpecClient
from .http import ResolveSpecError, merge_headers
from .resolvespec import AsyncResolveSpecClient, ResolveSpecClient
from .types import * # noqa: F401,F403
from .websocket import Subscription, WebSocketClient
def _cache_key(base_url: str, token: Optional[str], headers: Optional[Mapping[str, str]]):
return (
base_url,
token,
tuple(sorted((k.lower(), v) for k, v in (headers or {}).items())),
)
_resolvespec: dict = {}
_headerspec: dict = {}
def get_resolvespec_client(base_url: str, token: Optional[str] = None, headers: Optional[Mapping[str, str]] = None) -> ResolveSpecClient:
"""Cached sync client, keyed by base_url + token + headers (case-insensitive names)."""
key = _cache_key(base_url, token, headers)
if key not in _resolvespec:
_resolvespec[key] = ResolveSpecClient(base_url, token, headers)
return _resolvespec[key]
def get_headerspec_client(base_url: str, token: Optional[str] = None, headers: Optional[Mapping[str, str]] = None) -> HeaderSpecClient:
"""Cached sync client, keyed by base_url + token + headers (case-insensitive names)."""
key = _cache_key(base_url, token, headers)
if key not in _headerspec:
_headerspec[key] = HeaderSpecClient(base_url, token, headers)
return _headerspec[key]
@@ -0,0 +1,197 @@
"""FunctionSpec client: calls user-defined SQL endpoints (Go pkg/funcspec).
Routes are defined by the server application, so calls take a `path`.
Parameters are sent as query string values and/or `X-*` headers; the server never
reads a request body. Query-string values override headers of the same name.
Server behaviour worth knowing (pkg/funcspec):
- `sort` is inserted raw into ORDER BY, so it must be SQL (`col DESC`), not `-col`.
- Field selection (`X-Select-Fields`) is a no-op server-side, so it is not exposed.
- Only one search operator per column is kept.
- Values starting with `ZIP_` or `__` are base64-decoded by the server (even after our
own encoding), so such plaintext values cannot be sent faithfully.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional
import httpx
from .headerspec import _OPERATOR_MAP, _bool, _filter_value, encode_header_value
from .http import client_headers, error_from, merge_headers, parse_json
from .types import APIResponse, FuncSpecOptions
Params = Mapping[str, Any]
_CONTENT_RANGE = re.compile(r"(\d+)-(\d+)/(\d+)")
def _safe(value: str) -> str:
"""Encode values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces)."""
if not value.isascii() or not value.isprintable() or value != value.strip():
return encode_header_value(value)
return value
def build_headers(options: Mapping[str, Any]) -> Dict[str, str]:
"""Build the X-* headers understood by funcspec.ParseParameters."""
h: Dict[str, str] = {}
o = options
for f in o.get("filters") or []:
operator = f["operator"]
logic = f.get("logic_operator") or "AND"
value = _safe(_filter_value(f))
if operator == "eq" and logic == "AND":
h[f"X-FieldFilter-{f['column']}"] = value
else:
kind = "X-SearchOr" if logic == "OR" else "X-SearchOp"
h[f"{kind}-{_OPERATOR_MAP.get(operator, operator)}-{f['column']}"] = value
for col, text in (o.get("search_filters") or {}).items():
h[f"X-SearchFilter-{col}"] = _safe(str(text)) # CAST(col AS TEXT) ILIKE %text%
if o.get("custom_sql_where"):
h["X-Custom-SQL-W"] = _safe(o["custom_sql_where"])
if o.get("custom_sql_or"):
h["X-Custom-SQL-Or"] = _safe(o["custom_sql_or"])
if o.get("sort"):
h["X-Sort"] = _safe(",".join(_sort_term(s) for s in o["sort"]))
if o.get("limit") is not None:
h["X-Limit"] = str(o["limit"])
if o.get("offset") is not None:
h["X-Offset"] = str(o["offset"])
for name, key in (("X-Distinct", "distinct"), ("X-SkipCount", "skip_count"), ("X-SkipCache", "skip_cache")):
if o.get(key) is not None:
h[name] = _bool(o[key])
fmt = o.get("response_format")
if fmt:
h[{"simple": "X-SimpleApi", "detail": "X-DetailApi", "syncfusion": "X-Syncfusion"}[fmt]] = "true"
return h
def _sort_term(s: Mapping[str, str]) -> str:
# funcspec puts this verbatim into ORDER BY
return f"{s['column']} {'DESC' if s.get('direction', 'asc').upper() == 'DESC' else 'ASC'}"
def build_query(params: Optional[Params]) -> Dict[str, Any]:
"""Query-string values: bools -> true/false, lists -> repeated keys (server: IN filter)."""
out: Dict[str, Any] = {}
for k, v in (params or {}).items():
if v is None:
continue
if isinstance(v, (list, tuple)):
out[k] = [_safe(_q(x)) for x in v]
else:
out[k] = _safe(_q(v))
return out
def _q(v: Any) -> str:
return _bool(v) if isinstance(v, bool) else str(v)
def _metadata(response: httpx.Response, options: Optional[Mapping[str, Any]]) -> Dict[str, int]:
"""Content-Range is `items {offset}-{offset+len}/{total}`."""
m = _CONTENT_RANGE.search(response.headers.get("content-range", ""))
start, end, total = (int(x) for x in m.groups()) if m else (0, 0, 0)
return {
"total": total,
"count": end - start,
"filtered": total,
"offset": start,
"limit": int((options or {}).get("limit") or 0),
}
def _wrap(response: httpx.Response, options: Optional[Mapping[str, Any]], with_metadata: bool) -> APIResponse:
data = parse_json(response)
if not response.is_success: # 206 Partial Content is success
raise error_from(response, data)
result: APIResponse = {"success": True, "data": data}
if with_metadata:
result["metadata"] = _metadata(response, options)
return result
class _Base:
def __init__(
self,
base_url: str,
token: Optional[str] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: Optional[float] = 30.0,
):
self.base_url = base_url
self.token = token
self.headers = dict(headers or {}) # snapshot
self.timeout = timeout
def _req(self, method: str, path: str, params: Optional[Params], options: Optional[Mapping[str, Any]]):
url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
headers = merge_headers(
client_headers(self.token, self.headers),
build_headers(options) if options else {},
)
return method.upper(), url, headers, build_query(params)
class FuncSpecClient(_Base):
"""Synchronous client. Use as a context manager or call close()."""
def __init__(self, *args: Any, transport: Optional[httpx.BaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.Client(timeout=self.timeout, transport=transport)
def close(self) -> None:
self._http.close()
def __enter__(self) -> "FuncSpecClient":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
def _send(self, req, options, with_metadata) -> APIResponse:
method, url, headers, query = req
return _wrap(self._http.request(method, url, headers=headers, params=query), options, with_metadata)
def query(self, path: str, params: Optional[Params] = None, options: Optional[FuncSpecOptions] = None, *, method: str = "GET") -> APIResponse:
"""Single-record endpoint (Handler.SqlQuery). `data` is the row object."""
return self._send(self._req(method, path, params, options), options, False)
def query_list(self, path: str, params: Optional[Params] = None, options: Optional[FuncSpecOptions] = None, *, method: str = "GET") -> APIResponse:
"""List endpoint (Handler.SqlQueryList). Adds `metadata` from Content-Range."""
return self._send(self._req(method, path, params, options), options, True)
class AsyncFuncSpecClient(_Base):
"""Asyncio client. Use as an async context manager or await aclose()."""
def __init__(self, *args: Any, transport: Optional[httpx.AsyncBaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.AsyncClient(timeout=self.timeout, transport=transport)
async def aclose(self) -> None:
await self._http.aclose()
async def __aenter__(self) -> "AsyncFuncSpecClient":
return self
async def __aexit__(self, *exc: Any) -> None:
await self.aclose()
async def _send(self, req, options, with_metadata) -> APIResponse:
method, url, headers, query = req
return _wrap(await self._http.request(method, url, headers=headers, params=query), options, with_metadata)
async def query(self, path: str, params: Optional[Params] = None, options: Optional[FuncSpecOptions] = None, *, method: str = "GET") -> APIResponse:
return await self._send(self._req(method, path, params, options), options, False)
async def query_list(self, path: str, params: Optional[Params] = None, options: Optional[FuncSpecOptions] = None, *, method: str = "GET") -> APIResponse:
return await self._send(self._req(method, path, params, options), options, True)
@@ -0,0 +1,336 @@
"""HeaderSpec client: query options sent as HTTP headers (Go restheadspec).
Methods: GET=read, POST=create, PUT=update, DELETE=delete.
"""
from __future__ import annotations
import base64
import json
import re
from typing import Any, Dict, Mapping, Optional
import httpx
from .http import build_url, client_headers, error_from, merge_headers, parse_json
from .types import APIResponse, FilterOption, HeaderSpecOptions
_PREFIXES = ("ZIP_", "__")
_OPERATOR_MAP = {
"eq": "equals",
"neq": "notequals",
"gt": "greaterthan",
"gte": "greaterthanorequal",
"lt": "lessthan",
"lte": "lessthanorequal",
"like": "contains",
"ilike": "contains",
"contains": "contains",
"startswith": "beginswith",
"endswith": "endswith",
"in": "in",
"between": "between",
"between_inclusive": "betweeninclusive",
"is_null": "empty",
"is_not_null": "notempty",
}
def encode_header_value(value: str) -> str:
"""Base64 (UTF-8) with ZIP_ prefix, for complex header values."""
return "ZIP_" + base64.b64encode(value.encode("utf-8")).decode("ascii")
def decode_header_value(value: str) -> str:
"""Decode a value that may carry a ZIP_ or __ base64 prefix (nested allowed)."""
code = value
for prefix in _PREFIXES:
if code.startswith(prefix):
b64 = re.sub(r"[\n\r ]", "", code[len(prefix):])
b64 += "=" * (-len(b64) % 4)
code = base64.b64decode(b64).decode("utf-8")
break
if code.startswith(_PREFIXES):
code = decode_header_value(code)
return code
def _geo_header(operator: str) -> Optional[str]:
op = operator.lower()
if op.endswith("_within"):
return "X-VectorFilter-"
if op.startswith("st_") or op in ("bbox", "&&"):
return "X-SpatialFilter-"
return None
def _filter_value(f: FilterOption) -> str:
v = f.get("value")
if v is None:
return ""
if isinstance(v, (list, tuple)):
return ",".join(_scalar(x) for x in v)
return _scalar(v)
def _scalar(v: Any) -> str:
if isinstance(v, bool): # match JS String(true)
return "true" if v else "false"
return str(v)
def _bool(v: bool) -> str:
return "true" if v else "false"
def _preload_spec(p: Mapping[str, Any]) -> str:
cols = p.get("columns")
return f"{p['relation']}:{','.join(cols)}" if cols else p["relation"]
def build_headers(options: HeaderSpecOptions) -> Dict[str, str]:
"""Build restheadspec HTTP headers from options. See README for the mapping."""
h: Dict[str, str] = {}
o = options
if o.get("columns"):
h["X-Select-Fields"] = ",".join(o["columns"])
if o.get("omit_columns"):
h["X-Not-Select-Fields"] = ",".join(o["omit_columns"])
for f in o.get("filters") or []:
logic = f.get("logic_operator") or "AND"
operator = f["operator"]
op = _OPERATOR_MAP.get(operator, operator)
value = _filter_value(f)
geo = _geo_header(operator)
if geo:
payload: Dict[str, Any] = {"op": operator, "value": f.get("value")}
if logic == "OR":
payload["logic"] = "or"
h[f"{geo}{f['column']}"] = json.dumps(payload, separators=(",", ":"))
elif operator == "eq" and logic == "AND":
h[f"X-FieldFilter-{f['column']}"] = value
elif logic == "OR":
h[f"X-SearchOr-{op}-{f['column']}"] = value
else:
h[f"X-SearchOp-{op}-{f['column']}"] = value
if o.get("sort"):
h["X-Sort"] = ",".join(
("-" if s["direction"].upper() == "DESC" else "+") + s["column"] for s in o["sort"]
)
if o.get("limit") is not None:
h["X-Limit"] = str(o["limit"])
if o.get("offset") is not None:
h["X-Offset"] = str(o["offset"])
if o.get("cursor_forward"):
h["X-Cursor-Forward"] = o["cursor_forward"]
if o.get("cursor_backward"):
h["X-Cursor-Backward"] = o["cursor_backward"]
if o.get("preload"):
# Go applies X-Preload-Where to every preload in the matching X-Preload header,
# so preloads are grouped by where clause.
groups: Dict[str, list] = {}
for p in o["preload"]:
groups.setdefault(p.get("where") or "", []).append(_preload_spec(p))
n = 0
for where, specs in groups.items():
if not where:
h["X-Preload"] = "|".join(specs)
elif "" not in groups and n == 0:
# X-Preload-Where would also apply to a where-less X-Preload, so only use it alone
h["X-Preload"] = "|".join(specs)
h["X-Preload-Where"] = where
n += 1
else:
n += 1
h[f"X-Preload-{n}"] = "|".join(specs)
h[f"X-Preload-{n}-Where"] = where
if o.get("expand"):
h["X-Expand"] = "|".join(_preload_spec(e) for e in o["expand"])
if o.get("custom_sql_joins"):
h["X-Custom-SQL-Join"] = "|".join(o["custom_sql_joins"])
if o.get("custom_sql_or"):
h["X-Custom-SQL-Or"] = " OR ".join(o["custom_sql_or"])
if o.get("search_columns"):
h["X-SearchCols"] = ",".join(o["search_columns"])
for col, sql in (o.get("advanced_sql") or {}).items():
h[f"X-AdvSQL-{col}"] = sql
vs = o.get("vector_search")
if vs:
h[f"X-Vector-Search-{vs['column']}"] = vs.get("metric") or "l2"
h["X-Vector-Search-Vector"] = json.dumps(vs["vector"], separators=(",", ":"))
if vs.get("as"):
h["X-Vector-Search-As"] = vs["as"]
if vs.get("direction"):
h["X-Vector-Search-Dir"] = vs["direction"]
for name, key in (
("X-Clean-JSON", "clean_json"),
("X-Distinct", "distinct"),
("X-SkipCount", "skip_count"),
("X-SkipCache", "skip_cache"),
("X-Transaction-Atomic", "atomic_transaction"),
("X-Single-Record-As-Object", "single_record_as_object"),
):
if o.get(key) is not None:
h[name] = _bool(o[key])
if o.get("pk_row"):
h["X-PKRow"] = o["pk_row"]
fmt = o.get("response_format")
if fmt:
h[{"simple": "X-SimpleApi", "detail": "X-DetailApi", "syncfusion": "X-Syncfusion"}[fmt]] = "true"
if o.get("xfiles"):
h["X-Files"] = encode_header_value(json.dumps(o["xfiles"], separators=(",", ":")))
if o.get("fetch_row_number"):
h["X-Fetch-RowNumber"] = o["fetch_row_number"]
for cc in o.get("computedColumns") or []:
h[f"X-CQL-SEL-{cc['name']}"] = cc["expression"]
if o.get("customOperators"):
h["X-Custom-SQL-W"] = " AND ".join(co["sql"] for co in o["customOperators"])
return h
def _int(s: Optional[str]) -> int:
try:
return int(s) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
def _wrap(response: httpx.Response) -> APIResponse:
"""Wrap a raw restheadspec body, deriving metadata from Content-Range / X-Limit."""
data = parse_json(response)
if not response.is_success:
raise error_from(response, data)
cr = response.headers.get("content-range")
total = _int(cr.split("/")[-1]) if cr else 0
offset = _int(cr.split("/")[0].split("-")[0].split(" ")[-1]) if cr else 0
return {
"data": data,
"success": True,
"error": data.get("error") if isinstance(data, dict) else None,
"metadata": {
"count": total,
"total": total,
"filtered": total,
"offset": offset,
"limit": _int(response.headers.get("x-limit")),
},
}
class _Base:
def __init__(
self,
base_url: str,
token: Optional[str] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: Optional[float] = 30.0,
):
self.base_url = base_url
self.token = token
self.headers = dict(headers or {}) # snapshot
self.timeout = timeout
def _base_headers(self) -> Dict[str, str]:
return client_headers(self.token, self.headers)
def _req(self, method, schema, entity, id, options=None, body=None):
opt = build_headers(options) if options else {}
return (
method,
build_url(self.base_url, schema, entity, id),
merge_headers(self._base_headers(), opt),
body,
)
def _read_req(self, schema, entity, id, options):
return self._req("GET", schema, entity, id, options)
def _create_req(self, schema, entity, data, options):
return self._req("POST", schema, entity, None, options, data)
def _update_req(self, schema, entity, id, data, options):
return self._req("PUT", schema, entity, id, options, data)
def _delete_req(self, schema, entity, id):
return self._req("DELETE", schema, entity, id)
class HeaderSpecClient(_Base):
"""Synchronous client. Use as a context manager or call close()."""
def __init__(self, *args: Any, transport: Optional[httpx.BaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.Client(timeout=self.timeout, transport=transport)
def close(self) -> None:
self._http.close()
def __enter__(self) -> "HeaderSpecClient":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
def _send(self, req) -> APIResponse:
method, url, headers, body = req
return _wrap(self._http.request(method, url, headers=headers, json=body))
def read(self, schema: str, entity: str, id: Optional[str] = None, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return self._send(self._read_req(schema, entity, id, options))
def create(self, schema: str, entity: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return self._send(self._create_req(schema, entity, data, options))
def update(self, schema: str, entity: str, id: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return self._send(self._update_req(schema, entity, id, data, options))
def delete(self, schema: str, entity: str, id: str) -> APIResponse:
return self._send(self._delete_req(schema, entity, id))
class AsyncHeaderSpecClient(_Base):
"""Asyncio client. Use as an async context manager or await aclose()."""
def __init__(self, *args: Any, transport: Optional[httpx.AsyncBaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.AsyncClient(timeout=self.timeout, transport=transport)
async def aclose(self) -> None:
await self._http.aclose()
async def __aenter__(self) -> "AsyncHeaderSpecClient":
return self
async def __aexit__(self, *exc: Any) -> None:
await self.aclose()
async def _send(self, req) -> APIResponse:
method, url, headers, body = req
return _wrap(await self._http.request(method, url, headers=headers, json=body))
async def read(self, schema: str, entity: str, id: Optional[str] = None, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return await self._send(self._read_req(schema, entity, id, options))
async def create(self, schema: str, entity: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return await self._send(self._create_req(schema, entity, data, options))
async def update(self, schema: str, entity: str, id: str, data: Any, options: Optional[HeaderSpecOptions] = None) -> APIResponse:
return await self._send(self._update_req(schema, entity, id, data, options))
async def delete(self, schema: str, entity: str, id: str) -> APIResponse:
return await self._send(self._delete_req(schema, entity, id))
@@ -0,0 +1,76 @@
"""Shared HTTP helpers for the REST clients."""
from __future__ import annotations
from typing import Any, Dict, Mapping, Optional
from urllib.parse import quote
class ResolveSpecError(Exception):
"""Raised on a non-2xx response or an unsuccessful API result."""
def __init__(
self,
message: str,
status_code: Optional[int] = None,
code: Optional[str] = None,
details: Any = None,
detail: Optional[str] = None,
):
super().__init__(message)
self.message = message
self.status_code = status_code
self.code = code
self.details = details
self.detail = detail # server-side reason (funcspec / restheadspec errors)
def merge_headers(*sources: Mapping[str, str]) -> Dict[str, str]:
"""Merge HTTP headers case-insensitively; the last source wins and keeps its spelling."""
result: Dict[str, str] = {}
for source in sources:
for name, value in source.items():
for existing in [k for k in result if k.lower() == name.lower()]:
del result[existing]
result[name] = value
return result
def client_headers(token: Optional[str], headers: Optional[Mapping[str, str]]) -> Dict[str, str]:
"""Content-Type < custom headers < bearer token."""
return merge_headers(
{"Content-Type": "application/json"},
headers or {},
{"Authorization": f"Bearer {token}"} if token else {},
)
def build_url(base_url: str, schema: str, entity: str, id: Optional[Any] = None) -> str:
url = f"{base_url.rstrip('/')}/{quote(schema, safe='')}/{quote(entity, safe='')}"
if id is not None and id != "":
url += f"/{quote(str(id), safe='')}"
return url
def drop_none(d: Mapping[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
def parse_json(response: Any) -> Any:
try:
return response.json()
except ValueError:
return None
def error_from(response: Any, data: Any) -> ResolveSpecError:
err = data.get("error") if isinstance(data, dict) else None
err = err if isinstance(err, dict) else {}
text = (response.text or "").strip() if data is None else ""
fallback = text[:200] or f"{response.reason_phrase} ({response.status_code})"
return ResolveSpecError(
err.get("message") or fallback,
status_code=response.status_code,
code=err.get("code"),
details=err.get("details"),
detail=err.get("detail"),
)
@@ -0,0 +1,137 @@
"""ResolveSpec client: JSON body protocol (POST {operation, data, options})."""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Tuple
import httpx
from .http import build_url, client_headers, drop_none, error_from, parse_json
from .types import APIResponse, Options, RecordId
def _url_id(id: Optional[RecordId]) -> Optional[str]:
return str(id) if isinstance(id, (int, str)) else None
def _body_id(id: Optional[RecordId]) -> Optional[List[str]]:
return id if isinstance(id, list) else None
class _Base:
def __init__(
self,
base_url: str,
token: Optional[str] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: Optional[float] = 30.0,
):
self.base_url = base_url
self.token = token
self.headers = dict(headers or {}) # snapshot
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
return client_headers(self.token, self.headers)
def _request(
self, method: str, schema: str, entity: str, id: Optional[str], body: Optional[Dict[str, Any]]
) -> Tuple[str, str, Dict[str, str], Optional[Dict[str, Any]]]:
return method, build_url(self.base_url, schema, entity, id), self._headers(), body
@staticmethod
def _result(response: httpx.Response) -> APIResponse:
data = parse_json(response)
if not response.is_success:
raise error_from(response, data)
return data
# request builders (shared by sync and async)
def _metadata_req(self, schema, entity):
return self._request("GET", schema, entity, None, None)
def _read_req(self, schema, entity, id, options):
body = drop_none({"operation": "read", "id": _body_id(id), "options": options})
return self._request("POST", schema, entity, _url_id(id), body)
def _create_req(self, schema, entity, data, options):
body = drop_none({"operation": "create", "data": data, "options": options})
return self._request("POST", schema, entity, None, body)
def _update_req(self, schema, entity, data, id, options):
body = drop_none({"operation": "update", "id": _body_id(id), "data": data, "options": options})
return self._request("POST", schema, entity, _url_id(id), body)
def _delete_req(self, schema, entity, id):
return self._request("POST", schema, entity, str(id), {"operation": "delete"})
class ResolveSpecClient(_Base):
"""Synchronous client. Use as a context manager or call close()."""
def __init__(self, *args: Any, transport: Optional[httpx.BaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.Client(timeout=self.timeout, transport=transport)
def close(self) -> None:
self._http.close()
def __enter__(self) -> "ResolveSpecClient":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
def _send(self, req) -> APIResponse:
method, url, headers, body = req
return self._result(self._http.request(method, url, headers=headers, json=body))
def get_metadata(self, schema: str, entity: str) -> APIResponse:
return self._send(self._metadata_req(schema, entity))
def read(self, schema: str, entity: str, id: Optional[RecordId] = None, options: Optional[Options] = None) -> APIResponse:
return self._send(self._read_req(schema, entity, id, options))
def create(self, schema: str, entity: str, data: Any, options: Optional[Options] = None) -> APIResponse:
return self._send(self._create_req(schema, entity, data, options))
def update(self, schema: str, entity: str, data: Any, id: Optional[RecordId] = None, options: Optional[Options] = None) -> APIResponse:
return self._send(self._update_req(schema, entity, data, id, options))
def delete(self, schema: str, entity: str, id: Any) -> APIResponse:
return self._send(self._delete_req(schema, entity, id))
class AsyncResolveSpecClient(_Base):
"""Asyncio client. Use as an async context manager or await aclose()."""
def __init__(self, *args: Any, transport: Optional[httpx.AsyncBaseTransport] = None, **kwargs: Any):
super().__init__(*args, **kwargs)
self._http = httpx.AsyncClient(timeout=self.timeout, transport=transport)
async def aclose(self) -> None:
await self._http.aclose()
async def __aenter__(self) -> "AsyncResolveSpecClient":
return self
async def __aexit__(self, *exc: Any) -> None:
await self.aclose()
async def _send(self, req) -> APIResponse:
method, url, headers, body = req
return self._result(await self._http.request(method, url, headers=headers, json=body))
async def get_metadata(self, schema: str, entity: str) -> APIResponse:
return await self._send(self._metadata_req(schema, entity))
async def read(self, schema: str, entity: str, id: Optional[RecordId] = None, options: Optional[Options] = None) -> APIResponse:
return await self._send(self._read_req(schema, entity, id, options))
async def create(self, schema: str, entity: str, data: Any, options: Optional[Options] = None) -> APIResponse:
return await self._send(self._create_req(schema, entity, data, options))
async def update(self, schema: str, entity: str, data: Any, id: Optional[RecordId] = None, options: Optional[Options] = None) -> APIResponse:
return await self._send(self._update_req(schema, entity, data, id, options))
async def delete(self, schema: str, entity: str, id: Any) -> APIResponse:
return await self._send(self._delete_req(schema, entity, id))
@@ -0,0 +1,166 @@
"""Types aligned with Go pkg/common/types.go. Dict keys are the wire names."""
from __future__ import annotations
from typing import Any, Dict, List, NotRequired, TypedDict, Union
Operator = str # eq neq gt gte lt lte like ilike in contains startswith endswith
# between between_inclusive is_null is_not_null
# st_dwithin bbox (spatial) | l2_within cosine_within ip_within (vector)
Operation = str # read | create | update | delete
SortDirection = str # asc | desc | ASC | DESC
VectorMetric = str # l2 | cosine | ip
ResponseFormat = str # simple | detail | syncfusion
RecordId = Union[int, str, List[str]]
class Parameter(TypedDict):
name: str
value: str
sequence: NotRequired[int]
class FilterOption(TypedDict):
column: str
operator: str
value: Any
logic_operator: NotRequired[str] # "AND" | "OR"
class SortOption(TypedDict):
column: str
direction: str
class CustomOperator(TypedDict):
name: str
sql: str
class ComputedColumn(TypedDict):
name: str
expression: str
class PreloadOption(TypedDict, total=False):
relation: str
table_name: str
columns: List[str]
omit_columns: List[str]
sort: List[SortOption]
filters: List[FilterOption]
where: str
limit: int
offset: int
updateable: bool
computed_ql: Dict[str, str]
recursive: bool
primary_key: str
related_key: str
foreign_key: str
recursive_child_key: str
sql_joins: List[str]
join_aliases: List[str]
# `as` is a keyword, so the functional syntax is required.
VectorSearchOption = TypedDict(
"VectorSearchOption",
{
"column": str,
"vector": List[float],
"metric": str, # l2 (default) | cosine | ip
"as": str, # distance column alias, default _distance
"direction": str, # asc (default) | desc
},
total=False,
)
class ExpandOption(TypedDict, total=False):
relation: str
columns: List[str]
class XFiles(TypedDict, total=False):
tablename: str
schema: str
primarykey: str
foreignkey: str
relatedkey: str
sort: List[str]
prefix: str
editable: bool
recursive: bool
expand: bool
rownumber: bool
skipcount: bool
offset: int
limit: int
columns: List[str]
omit_columns: List[str]
cql_columns: List[str]
sql_joins: List[str]
sql_or: List[str]
sql_and: List[str]
parenttables: List["XFiles"]
childtables: List["XFiles"]
filter_fields: List[Dict[str, str]]
cursor_forward: str
cursor_backward: str
class Options(TypedDict, total=False):
preload: List[PreloadOption]
columns: List[str]
omit_columns: List[str]
filters: List[FilterOption]
sort: List[SortOption]
limit: int
offset: int
customOperators: List[CustomOperator]
computedColumns: List[ComputedColumn]
parameters: List[Parameter]
cursor_forward: str
cursor_backward: str
fetch_row_number: str
vector_search: VectorSearchOption
class HeaderSpecOptions(Options, total=False):
"""Options only available to the header-based (restheadspec) protocol."""
expand: List[ExpandOption] # X-Expand
custom_sql_joins: List[str] # X-Custom-SQL-Join
custom_sql_or: List[str] # X-Custom-SQL-Or
search_columns: List[str] # X-SearchCols
advanced_sql: Dict[str, str] # X-AdvSQL-{col}
clean_json: bool # X-Clean-JSON
distinct: bool # X-Distinct
skip_count: bool # X-SkipCount
skip_cache: bool # X-SkipCache
pk_row: str # X-PKRow
response_format: str # X-SimpleApi / X-DetailApi / X-Syncfusion
single_record_as_object: bool # X-Single-Record-As-Object
atomic_transaction: bool # X-Transaction-Atomic
xfiles: XFiles # X-Files
class FuncSpecOptions(TypedDict, total=False):
"""Options understood by funcspec endpoints (sent as X-* headers)."""
filters: List[FilterOption] # eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr (one per column)
search_filters: Dict[str, str] # X-SearchFilter-{col}: text ILIKE
custom_sql_where: str # X-Custom-SQL-W
custom_sql_or: str # X-Custom-SQL-Or
sort: List[SortOption] # sent as SQL ORDER BY terms ("col DESC")
limit: int
offset: int
distinct: bool
skip_count: bool
skip_cache: bool
response_format: str # simple | detail | syncfusion
# Responses are plain dicts: {"success", "data", "metadata"?, "error"?}
APIResponse = Dict[str, Any]
@@ -0,0 +1,335 @@
"""WebSocketSpec client (asyncio). Mirrors the Go websocketspec message protocol."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
from websockets.asyncio.client import ClientConnection, connect
from .http import ResolveSpecError
from .types import FilterOption, PreloadOption, SortOption
log = logging.getLogger("resolvespec.websocket")
# Connection states
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
DISCONNECTING = "disconnecting"
RECONNECTING = "reconnecting"
Notification = Dict[str, Any]
Callback = Callable[[Any], Union[None, Awaitable[None]]]
EVENTS = ("connect", "disconnect", "error", "message", "state_change")
@dataclass
class Subscription:
id: str
entity: str
schema: Optional[str] = None
options: Optional[Dict[str, Any]] = None
callback: Optional[Callback] = field(default=None, repr=False)
def _drop_none(d: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
class WebSocketClient:
"""
Usage:
async with WebSocketClient("ws://localhost:8080/ws") as ws:
rows = await ws.read("users", schema="public", limit=10)
Events (`on(event, callback)`): connect, disconnect, error, message, state_change.
Callbacks may be sync or async.
"""
def __init__(
self,
url: str,
*,
reconnect: bool = True,
reconnect_interval: float = 3.0,
max_reconnect_attempts: int = 10,
heartbeat_interval: float = 30.0,
request_timeout: float = 30.0,
subscribe_timeout: float = 10.0,
headers: Optional[Dict[str, str]] = None,
):
self.url = url
self.reconnect = reconnect
self.reconnect_interval = reconnect_interval
self.max_reconnect_attempts = max_reconnect_attempts
self.heartbeat_interval = heartbeat_interval
self.request_timeout = request_timeout
self.subscribe_timeout = subscribe_timeout
self.headers = dict(headers or {})
self._ws: Optional[ClientConnection] = None
self._state = DISCONNECTED
self._pending: Dict[str, "asyncio.Future[Dict[str, Any]]"] = {}
self._subscriptions: Dict[str, Subscription] = {}
self._listeners: Dict[str, Callback] = {}
self._tasks: List["asyncio.Task[Any]"] = []
self._reader: Optional["asyncio.Task[Any]"] = None
self._manual_close = False
# ---- lifecycle -------------------------------------------------------
async def __aenter__(self) -> "WebSocketClient":
await self.connect()
return self
async def __aexit__(self, *exc: Any) -> None:
await self.close()
async def connect(self) -> None:
if self.is_connected():
return
self._manual_close = False
self._set_state(CONNECTING)
try:
self._ws = await connect(self.url, additional_headers=self.headers or None)
except Exception as e:
self._set_state(DISCONNECTED)
await self._emit("error", e)
raise
self._set_state(CONNECTED)
self._reader = asyncio.create_task(self._read_loop(self._ws))
self._heartbeat = asyncio.create_task(self._heartbeat_loop())
await self._emit("connect")
async def close(self) -> None:
self._manual_close = True
self._set_state(DISCONNECTING)
for t in (self._reader, getattr(self, "_heartbeat", None), getattr(self, "_reconnect_task", None)):
if t and t is not asyncio.current_task():
t.cancel()
if self._ws:
await self._ws.close()
self._ws = None
self._fail_pending(ResolveSpecError("WebSocket closed"))
self._set_state(DISCONNECTED)
def is_connected(self) -> bool:
return self._ws is not None and self._state == CONNECTED
@property
def state(self) -> str:
return self._state
def on(self, event: str, callback: Callback) -> None:
if event not in EVENTS:
raise ValueError(f"unknown event {event!r}; expected one of {EVENTS}")
self._listeners[event] = callback
def off(self, event: str) -> None:
self._listeners.pop(event, None)
def get_subscriptions(self) -> List[Subscription]:
return list(self._subscriptions.values())
# ---- operations ------------------------------------------------------
async def request(
self,
operation: str,
entity: str,
*,
schema: Optional[str] = None,
record_id: Optional[str] = None,
data: Any = None,
options: Optional[Dict[str, Any]] = None,
) -> Any:
message = _drop_none({
"type": "request",
"operation": operation,
"entity": entity,
"schema": schema,
"record_id": record_id,
"data": data,
"options": options,
})
response = await self._call(message, self.request_timeout, "Request")
return response.get("data")
async def read(
self,
entity: str,
*,
schema: Optional[str] = None,
record_id: Optional[str] = None,
filters: Optional[List[FilterOption]] = None,
columns: Optional[List[str]] = None,
sort: Optional[List[SortOption]] = None,
preload: Optional[List[PreloadOption]] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> Any:
options = _drop_none({
"filters": filters, "columns": columns, "sort": sort,
"preload": preload, "limit": limit, "offset": offset,
})
return await self.request("read", entity, schema=schema, record_id=record_id, options=options)
async def create(self, entity: str, data: Any, *, schema: Optional[str] = None) -> Any:
return await self.request("create", entity, schema=schema, data=data)
async def update(self, entity: str, id: str, data: Any, *, schema: Optional[str] = None) -> Any:
return await self.request("update", entity, schema=schema, record_id=id, data=data)
async def delete(self, entity: str, id: str, *, schema: Optional[str] = None) -> None:
await self.request("delete", entity, schema=schema, record_id=id)
async def meta(self, entity: str, *, schema: Optional[str] = None) -> Any:
return await self.request("meta", entity, schema=schema)
async def subscribe(
self,
entity: str,
callback: Callback,
*,
schema: Optional[str] = None,
filters: Optional[List[FilterOption]] = None,
) -> str:
message = _drop_none({
"type": "subscription",
"operation": "subscribe",
"entity": entity,
"schema": schema,
"options": _drop_none({"filters": filters}),
})
response = await self._call(message, self.subscribe_timeout, "Subscription")
sub_id = (response.get("data") or {}).get("subscription_id")
if not sub_id:
raise ResolveSpecError("Subscription failed")
self._subscriptions[sub_id] = Subscription(
sub_id, entity, schema, _drop_none({"filters": filters}) or None, callback
)
return sub_id
async def unsubscribe(self, subscription_id: str) -> None:
message = {"type": "subscription", "operation": "unsubscribe", "subscription_id": subscription_id}
await self._call(message, self.subscribe_timeout, "Unsubscribe")
self._subscriptions.pop(subscription_id, None)
# ---- internals -------------------------------------------------------
async def _call(self, message: Dict[str, Any], timeout: float, what: str) -> Dict[str, Any]:
self._ensure_connected()
mid = str(uuid.uuid4())
message["id"] = mid
fut: "asyncio.Future[Dict[str, Any]]" = asyncio.get_running_loop().create_future()
self._pending[mid] = fut
try:
await self._ws.send(json.dumps(message)) # type: ignore[union-attr]
response = await asyncio.wait_for(fut, timeout)
except asyncio.TimeoutError:
raise ResolveSpecError(f"{what} timeout") from None
finally:
self._pending.pop(mid, None)
if not response.get("success"):
err = response.get("error") or {}
raise ResolveSpecError(
err.get("message") or f"{what} failed", code=err.get("code"), details=err.get("details")
)
return response
def _ensure_connected(self) -> None:
if not self.is_connected():
raise ResolveSpecError("WebSocket is not connected. Call connect() first.")
def _fail_pending(self, exc: Exception) -> None:
for fut in self._pending.values():
if not fut.done():
fut.set_exception(exc)
self._pending.clear()
async def _read_loop(self, ws: ClientConnection) -> None:
try:
async for raw in ws:
await self._handle_message(raw)
except asyncio.CancelledError:
raise
except Exception as e: # connection error
await self._emit("error", e)
# connection ended
if ws is not self._ws:
return
self._ws = None
if hb := getattr(self, "_heartbeat", None):
hb.cancel()
self._fail_pending(ResolveSpecError("WebSocket disconnected"))
self._set_state(DISCONNECTED)
await self._emit("disconnect", ws.close_code, ws.close_reason)
if self.reconnect and not self._manual_close:
self._reconnect_task = asyncio.create_task(self._reconnect())
async def _reconnect(self) -> None:
for attempt in range(1, self.max_reconnect_attempts + 1):
if self._manual_close:
return
log.debug("Reconnection attempt %d/%d", attempt, self.max_reconnect_attempts)
self._set_state(RECONNECTING)
await asyncio.sleep(self.reconnect_interval)
try:
await self.connect()
return
except Exception as e:
log.debug("Reconnection failed: %s", e)
self._set_state(DISCONNECTED)
async def _handle_message(self, raw: Union[str, bytes]) -> None:
try:
message = json.loads(raw)
except ValueError as e:
log.debug("Error parsing message: %s", e)
return
await self._emit("message", message)
kind = message.get("type")
if kind == "response":
fut = self._pending.get(message.get("id"))
if fut and not fut.done():
fut.set_result(message)
elif kind == "notification":
sub = self._subscriptions.get(message.get("subscription_id"))
if sub and sub.callback:
await _maybe_await(sub.callback(message))
elif kind != "pong":
log.debug("Unknown message type: %s", kind)
async def _heartbeat_loop(self) -> None:
try:
while True:
await asyncio.sleep(self.heartbeat_interval)
if self.is_connected():
await self._ws.send(json.dumps({"id": str(uuid.uuid4()), "type": "ping"})) # type: ignore[union-attr]
except asyncio.CancelledError:
raise
except Exception as e:
log.debug("Heartbeat failed: %s", e)
def _set_state(self, state: str) -> None:
if self._state != state:
self._state = state
cb = self._listeners.get("state_change")
if cb:
res = cb(state)
if asyncio.iscoroutine(res):
asyncio.ensure_future(res)
async def _emit(self, event: str, *args: Any) -> None:
cb = self._listeners.get(event)
if cb:
await _maybe_await(cb(*args))
async def _maybe_await(result: Any) -> None:
if asyncio.iscoroutine(result) or isinstance(result, asyncio.Future):
await result
@@ -0,0 +1,132 @@
import httpx
import pytest
from resolvespec import AsyncFuncSpecClient, FuncSpecClient, ResolveSpecError
from resolvespec.funcspec import build_headers, build_query
from resolvespec.headerspec import decode_header_value
def make(handler, **kw):
return FuncSpecClient("http://localhost:3000", "tok", transport=httpx.MockTransport(handler), **kw)
def capture(status=200, body=None, headers=None):
seen = []
def handler(req):
seen.append(req)
return httpx.Response(status, json=body if body is not None else [], headers=headers)
return seen, handler
def test_filters():
h = build_headers({"filters": [
{"column": "status", "operator": "eq", "value": "active"},
{"column": "age", "operator": "gte", "value": 18},
{"column": "name", "operator": "contains", "value": "x", "logic_operator": "OR"},
{"column": "deleted", "operator": "is_null", "value": None},
{"column": "id", "operator": "in", "value": [1, 2]},
{"column": "p", "operator": "between_inclusive", "value": [1, 5]},
]})
assert h == {
"X-FieldFilter-status": "active",
"X-SearchOp-greaterthanorequal-age": "18",
"X-SearchOr-contains-name": "x",
"X-SearchOp-empty-deleted": "",
"X-SearchOp-in-id": "1,2",
"X-SearchOp-betweeninclusive-p": "1,5",
}
def test_sort_is_sql_not_prefixed():
# server inserts sort verbatim into ORDER BY; "-col" would negate the column
h = build_headers({"sort": [{"column": "name", "direction": "asc"}, {"column": "created_at", "direction": "DESC"}]})
assert h["X-Sort"] == "name ASC,created_at DESC"
def test_misc_options():
h = build_headers({
"search_filters": {"name": "bob"}, "custom_sql_where": "a = 1", "custom_sql_or": "b = 2",
"limit": 5, "offset": 10, "distinct": True, "skip_count": True, "skip_cache": False,
"response_format": "syncfusion",
})
assert h == {
"X-SearchFilter-name": "bob", "X-Custom-SQL-W": "a = 1", "X-Custom-SQL-Or": "b = 2",
"X-Limit": "5", "X-Offset": "10", "X-Distinct": "true", "X-SkipCount": "true",
"X-SkipCache": "false", "X-Syncfusion": "true",
}
def test_ambiguous_values_are_encoded():
h = build_headers({"custom_sql_where": "name = 'café'", "filters": [{"column": "c", "operator": "eq", "value": " pad "}]})
assert h["X-Custom-SQL-W"].startswith("ZIP_")
assert decode_header_value(h["X-Custom-SQL-W"]) == "name = 'café'"
assert decode_header_value(h["X-FieldFilter-c"]) == " pad "
def test_build_query():
q = build_query({"p-id": 5, "flag": True, "ids": [1, 2], "skip": None, "m": "match=ab"})
assert q == {"p-id": "5", "flag": "true", "ids": ["1", "2"], "m": "match=ab"}
def test_query_list_request_and_metadata():
seen, h = capture(206, [{"id": 1}, {"id": 2}], {"content-range": "items 10-12/50"})
with make(h) as c:
res = c.query_list("/api/orders", {"p-status": "open", "id": [1, 2]}, {"limit": 2, "offset": 10})
r = seen[0]
assert r.method == "GET"
assert r.url.path == "/api/orders"
assert r.url.params.multi_items() == [("p-status", "open"), ("id", "1"), ("id", "2")]
assert r.headers["x-limit"] == "2" and r.headers["authorization"] == "Bearer tok"
assert res == {
"success": True,
"data": [{"id": 1}, {"id": 2}],
"metadata": {"total": 50, "count": 2, "filtered": 50, "offset": 10, "limit": 2},
}
def test_query_list_empty_result():
seen, h = capture(200, [], {"content-range": "items 0-0/0"})
with make(h) as c:
assert c.query_list("orders")["metadata"]["total"] == 0
assert seen[0].url.path == "/orders"
def test_query_single_has_no_metadata_and_method():
seen, h = capture(200, {"id": 1})
with make(h) as c:
res = c.query("api/order", method="post")
assert seen[0].method == "POST"
assert res == {"success": True, "data": {"id": 1}}
def test_detail_format_data_passthrough():
body = {"items": [{"a": 1}], "count": "1", "total": "1", "tablename": "/x", "tableprefix": "gsql"}
_, h = capture(200, body, {"content-range": "items 0-1/1"})
with make(h) as c:
assert c.query_list("x", options={"response_format": "detail"})["data"] == body
def test_server_error_shape():
err = {"success": False, "error": {"code": "query_failed", "message": "Failed to retrieve records", "detail": "no such column", "sql": "SELECT"}}
_, h = capture(400, err)
with make(h) as c:
with pytest.raises(ResolveSpecError, match="Failed to retrieve") as ei:
c.query_list("x")
assert ei.value.code == "query_failed" and ei.value.detail == "no such column" and ei.value.status_code == 400
def test_plain_text_panic_error():
with make(lambda r: httpx.Response(500, text="Internal server error: boom")) as c:
with pytest.raises(ResolveSpecError, match="boom"):
c.query("x")
async def test_async():
async def handler(req):
return httpx.Response(200, json=[{"id": 1}], headers={"content-range": "items 0-1/1"})
async with AsyncFuncSpecClient("http://localhost:3000", transport=httpx.MockTransport(handler)) as c:
assert (await c.query_list("x"))["metadata"]["total"] == 1
assert (await c.query("x"))["data"] == [{"id": 1}]
@@ -0,0 +1,236 @@
import json
import httpx
import pytest
from resolvespec import (
AsyncHeaderSpecClient,
HeaderSpecClient,
ResolveSpecError,
build_headers,
decode_header_value,
encode_header_value,
get_headerspec_client,
)
import base64
CFG = dict(base_url="http://localhost:3000", token="tok")
# ---- build_headers (ported from headerspec.test.ts) ----
def test_preload_shared_where():
h = build_headers({"preload": [
{"relation": "Items", "columns": ["id"], "where": "active = true"},
{"relation": "Tags", "where": "active = true"},
]})
assert h["X-Preload"] == "Items:id|Tags"
assert h["X-Preload-Where"] == "active = true"
def test_preload_mixed_where_numbered():
h = build_headers({"preload": [
{"relation": "Items", "where": "a = 1"},
{"relation": "Category"},
{"relation": "Tags", "where": "b = 2"},
]})
assert h["X-Preload"] == "Category"
assert "X-Preload-Where" not in h
assert h["X-Preload-1"] == "Items" and h["X-Preload-1-Where"] == "a = 1"
assert h["X-Preload-2"] == "Tags" and h["X-Preload-2-Where"] == "b = 2"
def test_expand_joins_or_searchcols_advsql():
h = build_headers({
"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"},
})
assert h["X-Expand"] == "Dept:id,name|Role"
assert h["X-Custom-SQL-Join"] == "LEFT JOIN a ON a.id = b.id|INNER JOIN c ON c.id = b.cid"
assert h["X-Custom-SQL-Or"] == "x = 1 OR y = 2"
assert h["X-SearchCols"] == "name,email"
assert h["X-AdvSQL-total"] == "a + b"
def test_flags_pkrow_format():
h = build_headers({
"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",
})
assert h["X-Clean-JSON"] == "true"
assert h["X-Distinct"] == "true"
assert h["X-SkipCount"] == "true"
assert h["X-SkipCache"] == "false"
assert h["X-Transaction-Atomic"] == "true"
assert h["X-Single-Record-As-Object"] == "false"
assert h["X-PKRow"] == "42"
assert h["X-DetailApi"] == "true"
def test_spatial_and_vector_filters():
h = build_headers({"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}},
]})
assert json.loads(h["X-SpatialFilter-geom"]) == {
"op": "st_dwithin", "value": {"geom": "POINT(0 0)", "distance": 5}, "logic": "or"}
assert json.loads(h["X-VectorFilter-emb"])["op"] == "cosine_within"
def test_vector_search():
h = build_headers({"vector_search": {"column": "emb", "vector": [0.1, 0.2], "metric": "cosine", "as": "dist", "direction": "desc"}})
assert h["X-Vector-Search-emb"] == "cosine"
assert h["X-Vector-Search-Vector"] == "[0.1,0.2]"
assert h["X-Vector-Search-As"] == "dist"
assert h["X-Vector-Search-Dir"] == "desc"
def test_xfiles_zip():
xf = {"tablename": "users", "prefix": "USR", "limit": 10}
h = build_headers({"xfiles": xf})
assert h["X-Files"].startswith("ZIP_")
assert json.loads(decode_header_value(h["X-Files"])) == xf
def test_columns_and_omit():
assert build_headers({"columns": ["id", "name", "email"]})["X-Select-Fields"] == "id,name,email"
assert build_headers({"omit_columns": ["secret", "internal"]})["X-Not-Select-Fields"] == "secret,internal"
def test_filters():
assert build_headers({"filters": [{"column": "status", "operator": "eq", "value": "active"}]})["X-FieldFilter-status"] == "active"
assert build_headers({"filters": [{"column": "age", "operator": "gte", "value": 18}]})["X-SearchOp-greaterthanorequal-age"] == "18"
assert build_headers({"filters": [{"column": "name", "operator": "contains", "value": "test", "logic_operator": "OR"}]})["X-SearchOr-contains-name"] == "test"
assert build_headers({"filters": [{"column": "price", "operator": "between", "value": [10, 100]}]})["X-SearchOp-between-price"] == "10,100"
assert build_headers({"filters": [{"column": "deleted_at", "operator": "is_null", "value": None}]})["X-SearchOp-empty-deleted_at"] == ""
assert build_headers({"filters": [{"column": "id", "operator": "in", "value": [1, 2, 3]}]})["X-SearchOp-in-id"] == "1,2,3"
assert build_headers({"filters": [{"column": "a", "operator": "eq", "value": True}]})["X-FieldFilter-a"] == "true"
def test_sort_pagination_cursor():
h = build_headers({
"sort": [{"column": "name", "direction": "asc"}, {"column": "created_at", "direction": "DESC"}],
"limit": 25, "offset": 0, "cursor_forward": "abc", "cursor_backward": "xyz",
})
assert h["X-Sort"] == "+name,-created_at"
assert h["X-Limit"] == "25" and h["X-Offset"] == "0"
assert h["X-Cursor-Forward"] == "abc" and h["X-Cursor-Backward"] == "xyz"
def test_preload_basic_rownumber_computed_custom():
h = build_headers({
"preload": [{"relation": "Items", "columns": ["id", "name"]}, {"relation": "Category"}],
"fetch_row_number": "42",
"computedColumns": [{"name": "total", "expression": "price * qty"}],
"customOperators": [{"name": "a", "sql": "status = 'active'"}, {"name": "v", "sql": "verified = true"}],
})
assert h["X-Preload"] == "Items:id,name|Category"
assert h["X-Fetch-RowNumber"] == "42"
assert h["X-CQL-SEL-total"] == "price * qty"
assert h["X-Custom-SQL-W"] == "status = 'active' AND verified = true"
def test_empty_options():
assert build_headers({}) == {}
# ---- encode / decode ----
def test_roundtrip():
for s in ("some complex value with spaces & symbols!", "café ☕ 你好"):
enc = encode_header_value(s)
assert enc.startswith("ZIP_")
assert decode_header_value(enc) == s
def test_decode_double_underscore_and_plain():
assert decode_header_value("__" + base64.b64encode(b"hello").decode()) == "hello"
assert decode_header_value("__" + base64.b64encode("café ☕".encode()).decode()) == "café ☕"
assert decode_header_value("plain") == "plain"
def test_decode_nested():
assert decode_header_value(encode_header_value(encode_header_value("x"))) == "x"
# ---- client ----
def make(handler, cls=HeaderSpecClient, **kw):
return cls(**{**CFG, **kw}, transport=httpx.MockTransport(handler))
def test_read_sends_get_with_headers():
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json=[{"id": 1}], headers={"content-range": "0-9/100", "x-limit": "10"})
with make(handler) as c:
res = c.read("public", "users", options={"columns": ["id", "name"], "limit": 10})
r = seen[0]
assert str(r.url) == "http://localhost:3000/public/users"
assert r.method == "GET"
assert r.headers["x-select-fields"] == "id,name"
assert r.headers["x-limit"] == "10"
assert r.headers["authorization"] == "Bearer tok"
assert res["success"] is True
assert res["data"] == [{"id": 1}]
assert res["metadata"] == {"count": 100, "total": 100, "filtered": 100, "offset": 0, "limit": 10}
def test_metadata_defaults_without_content_range():
with make(lambda r: httpx.Response(200, json=[])) as c:
assert c.read("public", "users")["metadata"]["total"] == 0
def test_read_with_id_create_update_delete():
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={})
with make(handler) as c:
c.read("public", "users", "42")
c.create("public", "users", {"name": "Test"})
c.update("public", "users", "1", {"name": "Updated"}, {"filters": [{"column": "active", "operator": "eq", "value": True}]})
c.delete("public", "users", "1")
assert str(seen[0].url) == "http://localhost:3000/public/users/42"
assert seen[1].method == "POST" and json.loads(seen[1].content) == {"name": "Test"}
assert seen[2].method == "PUT" and str(seen[2].url).endswith("/public/users/1")
assert seen[2].headers["x-fieldfilter-active"] == "true"
assert seen[3].method == "DELETE"
def test_error_response():
with make(lambda r: httpx.Response(400, json={"error": {"code": "err", "message": "fail"}})) as c:
with pytest.raises(ResolveSpecError, match="fail") as ei:
c.read("public", "users")
assert ei.value.status_code == 400 and ei.value.code == "err"
def test_error_non_json():
with make(lambda r: httpx.Response(502, text="bad gateway")) as c:
with pytest.raises(ResolveSpecError, match="bad gateway") as ei:
c.read("public", "users")
assert ei.value.status_code == 502
async def test_async_client():
async def handler(req):
return httpx.Response(200, json=[{"id": 1}])
async with AsyncHeaderSpecClient(**CFG, transport=httpx.MockTransport(handler)) as c:
res = await c.read("public", "users", options={"limit": 1})
assert res["data"] == [{"id": 1}]
def test_singleton():
a = get_headerspec_client("http://hs-singleton:3000")
assert a is get_headerspec_client("http://hs-singleton:3000")
assert a is not get_headerspec_client("http://hs-singleton-b:3000")
@@ -0,0 +1,210 @@
import json
import httpx
import pytest
from resolvespec import (
AsyncHeaderSpecClient,
AsyncResolveSpecClient,
HeaderSpecClient,
ResolveSpecClient,
ResolveSpecError,
get_headerspec_client,
get_resolvespec_client,
)
CFG = dict(base_url="http://localhost:3000", token="test-token")
def make(handler, **kw):
return ResolveSpecClient(**{**CFG, **kw}, transport=httpx.MockTransport(handler))
def ok(_req):
return httpx.Response(200, json={"success": True, "data": [{"id": 1}]})
def capture():
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={"success": True, "data": {"id": 1, "name": "Test"}})
return seen, handler
def body(req):
return json.loads(req.content)
def test_read_with_numeric_id():
seen, h = capture()
with make(h) as c:
assert c.read("public", "users", 1)["success"] is True
r = seen[0]
assert str(r.url) == "http://localhost:3000/public/users/1"
assert r.method == "POST"
assert r.headers["authorization"] == "Bearer test-token"
assert r.headers["content-type"] == "application/json"
assert body(r) == {"operation": "read"}
def test_read_array_id_goes_in_body():
seen, h = capture()
with make(h) as c:
c.read("public", "users", ["1", "2"])
assert str(seen[0].url) == "http://localhost:3000/public/users"
assert body(seen[0])["id"] == ["1", "2"]
def test_read_options_passthrough():
seen, h = capture()
opts = {
"columns": ["id", "name"], "omit_columns": ["secret"],
"filters": [{"column": "active", "operator": "eq", "value": True}],
"sort": [{"column": "name", "direction": "asc"}],
"limit": 10, "offset": 0, "cursor_forward": "cursor1", "fetch_row_number": "5",
"customOperators": [{"name": "x", "sql": "a = 1"}],
}
with make(h) as c:
c.read("public", "users", options=opts)
assert body(seen[0])["options"] == opts
def test_create():
seen, h = capture()
with make(h) as c:
res = c.create("public", "users", {"name": "Test"})
assert res["data"]["name"] == "Test"
assert body(seen[0]) == {"operation": "create", "data": {"name": "Test"}}
def test_create_batch():
seen, h = capture()
with make(h) as c:
c.create("public", "users", [{"a": 1}, {"a": 2}])
assert body(seen[0])["data"] == [{"a": 1}, {"a": 2}]
def test_update_with_id_in_url_and_array():
seen, h = capture()
with make(h) as c:
c.update("public", "users", {"name": "X"}, 5)
c.update("public", "users", {"name": "X"}, ["1", "2"])
assert str(seen[0].url).endswith("/public/users/5")
assert body(seen[0]) == {"operation": "update", "data": {"name": "X"}}
assert str(seen[1].url).endswith("/public/users")
assert body(seen[1])["id"] == ["1", "2"]
def test_update_preserves_empty_string_and_null():
seen, h = capture()
with make(h) as c:
c.update("public", "users", {"a": "", "b": None}, 1)
assert body(seen[0])["data"] == {"a": "", "b": None}
def test_delete():
seen, h = capture()
with make(h) as c:
c.delete("public", "users", 1)
assert str(seen[0].url).endswith("/public/users/1")
assert body(seen[0]) == {"operation": "delete"}
def test_get_metadata():
seen, h = capture()
with make(h) as c:
c.get_metadata("public", "users")
assert seen[0].method == "GET"
assert str(seen[0].url) == "http://localhost:3000/public/users"
assert not seen[0].content
def test_error_uses_server_message():
with make(lambda r: httpx.Response(404, json={"success": False, "error": {"code": "not_found", "message": "nope"}})) as c:
with pytest.raises(ResolveSpecError, match="nope") as ei:
c.read("public", "users", 1)
assert ei.value.status_code == 404 and ei.value.code == "not_found"
def test_id_is_url_quoted():
seen, h = capture()
with make(h) as c:
c.read("public", "users", "a/b")
assert str(seen[0].url).endswith("/public/users/a%2Fb")
def test_trailing_slash_base_url():
seen, h = capture()
with make(h, base_url="http://localhost:3000/") as c:
c.read("public", "users")
assert str(seen[0].url) == "http://localhost:3000/public/users"
async def test_async_client():
async def handler(req):
return httpx.Response(200, json={"success": True, "data": [1]})
async with AsyncResolveSpecClient(**CFG, transport=httpx.MockTransport(handler)) as c:
assert (await c.read("public", "users"))["data"] == [1]
assert (await c.create("public", "users", {}))["success"]
assert (await c.update("public", "users", {}, 1))["success"]
assert (await c.delete("public", "users", 1))["success"]
assert (await c.get_metadata("public", "users"))["success"]
# ---- custom headers (ported from custom-headers.test.ts) ----
@pytest.mark.parametrize("cls", [ResolveSpecClient, HeaderSpecClient])
def test_custom_headers_on_every_op_case_insensitive(cls):
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={"success": True, "data": []})
headers = {"X-Tenant": "acme", "authorization": "Basic ignored",
"content-type": "application/custom+json", "x-limit": "99"}
with cls("http://localhost:3000", "tok", headers, transport=httpx.MockTransport(handler)) as c:
c.read("public", "users", options={"limit": 10})
c.create("public", "users", {})
if cls is ResolveSpecClient:
c.update("public", "users", {}, "1")
c.get_metadata("public", "users")
else:
c.update("public", "users", "1", {})
c.delete("public", "users", "1")
for r in seen:
assert r.headers["x-tenant"] == "acme"
assert r.headers["authorization"] == "Bearer tok"
assert r.headers["content-type"] == "application/custom+json"
if cls is HeaderSpecClient:
assert seen[0].headers["x-limit"] == "10"
assert headers["authorization"] == "Basic ignored"
assert headers["x-limit"] == "99"
@pytest.mark.parametrize("cls", [ResolveSpecClient, HeaderSpecClient])
def test_custom_auth_without_token(cls):
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={"success": True, "data": []})
with cls("http://localhost:3000", headers={"Authorization": "Basic custom"}, transport=httpx.MockTransport(handler)) as c:
c.read("public", "users")
assert seen[0].headers["authorization"] == "Basic custom"
@pytest.mark.parametrize("factory", [get_resolvespec_client, get_headerspec_client])
def test_cache_isolation_and_snapshot(factory):
headers = {"X-Tenant": "acme", "X-App": "grid"}
first = factory("http://tenant-cache", "one", headers)
assert factory("http://tenant-cache", "one", {"x-app": "grid", "x-tenant": "acme"}) is first
assert factory("http://tenant-cache", "two", headers) is not first
headers["X-Tenant"] = "other"
assert factory("http://tenant-cache", "one", headers) is not first
assert first.headers["X-Tenant"] == "acme"
@@ -0,0 +1,152 @@
import asyncio
import json
import pytest
from websockets.asyncio.server import serve
from resolvespec import ResolveSpecError, WebSocketClient
class Server:
"""Minimal in-process WebSocketSpec server."""
def __init__(self):
self.received = []
self.conns = set()
self.respond = True
async def handler(self, ws):
self.conns.add(ws)
try:
async for raw in ws:
msg = json.loads(raw)
self.received.append(msg)
if msg["type"] == "ping":
await ws.send(json.dumps({"type": "pong"}))
continue
if not self.respond:
continue
await ws.send(json.dumps(self.reply(msg)))
finally:
self.conns.discard(ws)
def reply(self, msg):
base = {"id": msg["id"], "type": "response", "success": True, "timestamp": "t"}
if msg["type"] == "subscription" and msg["operation"] == "subscribe":
return {**base, "data": {"subscription_id": "sub-1"}}
if msg.get("entity") == "fail":
return {**base, "success": False, "error": {"code": "bad", "message": "boom"}}
return {**base, "data": {"echo": msg.get("operation"), "record_id": msg.get("record_id")}}
@pytest.fixture
async def server():
s = Server()
async with serve(s.handler, "127.0.0.1", 0) as srv:
s.url = "ws://127.0.0.1:%d" % srv.sockets[0].getsockname()[1]
yield s
async def test_operations_and_message_shape(server):
async with WebSocketClient(server.url, reconnect=False) as c:
assert c.state == "connected"
assert await c.read("users", schema="public", record_id="1", limit=5, filters=[{"column": "a", "operator": "eq", "value": 1}]) == {"echo": "read", "record_id": "1"}
await c.create("users", {"n": 1}, schema="public")
await c.update("users", "2", {"n": 2})
await c.delete("users", "3")
await c.meta("users")
m = server.received
assert m[0]["type"] == "request" and m[0]["operation"] == "read"
assert m[0]["schema"] == "public" and m[0]["record_id"] == "1"
assert m[0]["options"] == {"filters": [{"column": "a", "operator": "eq", "value": 1}], "limit": 5}
assert m[1]["data"] == {"n": 1}
assert m[2]["record_id"] == "2"
assert [x["operation"] for x in m] == ["read", "create", "update", "delete", "meta"]
assert "schema" not in m[2]
assert len({x["id"] for x in m}) == 5
async def test_error_response_raises(server):
async with WebSocketClient(server.url, reconnect=False) as c:
with pytest.raises(ResolveSpecError, match="boom") as ei:
await c.read("fail")
assert ei.value.code == "bad"
async def test_request_timeout(server):
server.respond = False
async with WebSocketClient(server.url, reconnect=False, request_timeout=0.1) as c:
with pytest.raises(ResolveSpecError, match="timeout"):
await c.read("users")
assert not c._pending
async def test_not_connected_raises():
c = WebSocketClient("ws://127.0.0.1:1")
with pytest.raises(ResolveSpecError, match="not connected"):
await c.read("users")
async def test_subscribe_notify_unsubscribe(server):
got = asyncio.Queue()
async with WebSocketClient(server.url, reconnect=False) as c:
sid = await c.subscribe("users", got.put, schema="public", filters=[{"column": "a", "operator": "eq", "value": 1}])
assert sid == "sub-1"
assert [s.id for s in c.get_subscriptions()] == ["sub-1"]
assert server.received[0]["operation"] == "subscribe"
assert server.received[0]["options"] == {"filters": [{"column": "a", "operator": "eq", "value": 1}]}
for ws in server.conns:
await ws.send(json.dumps({"type": "notification", "operation": "create", "subscription_id": "sub-1",
"entity": "users", "data": {"id": 9}, "timestamp": "t"}))
n = await asyncio.wait_for(got.get(), 2)
assert n["data"] == {"id": 9}
await c.unsubscribe("sub-1")
assert c.get_subscriptions() == []
assert server.received[-1] == {**server.received[-1], "operation": "unsubscribe", "subscription_id": "sub-1"}
async def test_events_and_heartbeat(server):
events = []
c = WebSocketClient(server.url, reconnect=False, heartbeat_interval=0.05)
c.on("connect", lambda: events.append("connect"))
c.on("state_change", lambda s: events.append(s))
c.on("message", lambda m: events.append(("msg", m["type"])))
await c.connect()
await asyncio.sleep(0.2)
await c.close()
assert events[:3] == ["connecting", "connected", "connect"]
assert ("msg", "pong") in events
assert events[-1] == "disconnected"
assert any(m["type"] == "ping" for m in server.received)
with pytest.raises(ValueError):
c.on("bogus", lambda: None)
async def test_reconnect_after_server_drop(server):
states = []
c = WebSocketClient(server.url, reconnect=True, reconnect_interval=0.05)
c.on("state_change", states.append)
await c.connect()
for ws in list(server.conns):
await ws.close()
for _ in range(100):
if states.count("connected") >= 2:
break
await asyncio.sleep(0.05)
assert "reconnecting" in states
assert c.is_connected()
assert (await c.read("users"))["echo"] == "read"
await c.close()
async def test_pending_requests_fail_on_disconnect(server):
server.respond = False
c = WebSocketClient(server.url, reconnect=False)
await c.connect()
task = asyncio.create_task(c.read("users"))
await asyncio.sleep(0.05)
for ws in list(server.conns):
await ws.close()
with pytest.raises(ResolveSpecError, match="disconnected"):
await asyncio.wait_for(task, 2)
await c.close()
+50
View File
@@ -0,0 +1,50 @@
# ResolveSpec Python Client - TODO
## Client Implementation & Testing
### 1. ResolveSpec Client API
- [x] Core API implementation (read, create, update, delete, get_metadata)
- [x] Unit tests for API functions
- [ ] Integration tests with server
- [x] Error handling and edge cases
### 2. HeaderSpec Client API
- [x] Client API implementation
- [x] Unit tests
- [ ] Integration tests with server
### 3. FunctionSpec Client API
- [x] Client API implementation
- [x] Unit tests
- [ ] Integration tests with server
### 4. WebSocketSpec Client API
- [x] WebSocketClient class implementation (read, create, update, delete, meta, subscribe, unsubscribe)
- [x] Unit tests for WebSocketClient
- [x] Connection handling tests
- [x] Subscription tests
- [ ] Integration tests with server
### 5. Testing Infrastructure
- [x] Set up test framework (pytest)
- [ ] Configure test coverage reporting (pytest-cov)
- [ ] Add test utilities and fixtures
- [ ] Create test documentation
- [ ] Package and publish to PyPI
## Documentation
- [ ] API reference documentation
- [ ] Usage examples for each client API
- [ ] Installation guide
- [ ] Contributing guidelines
- [x] README (cheatsheet)
---
**Last Updated:** 2026-09-30