mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 12:31:59 +00:00
refactor: move JS and Python clients under clients/
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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` |
|
||||
@@ -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.
|
||||
+1
File diff suppressed because one or more lines are too long
+5
@@ -0,0 +1,5 @@
|
||||
export * from './common';
|
||||
export * from './resolvespec';
|
||||
export * from './websocketspec';
|
||||
export * from './headerspec';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
Vendored
+432
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+3366
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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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__"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user