diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b43d88c --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy to .env and fill in. Used only by the integration suite (__tests__/index.test.ts), +# which is skipped unless `url` and `odx_api_key` are set. .env is git-ignored. + +# --- Odoo instance (carried inside each request body) --- +url=https://your-odoo.example.com # Base URL of your Odoo server +db=your-db # Odoo database name +uid=2 # Odoo user id (alias: user_id) +api_key=your-odoo-user-api-key # API key of that Odoo user + +# --- ODXProxy gateway --- +odx_api_key=your-odxproxy-gateway-api-key # Proxy x-api-key + +# Optional: only needed for a self-hosted proxy. Defaults to https://gateway.odxproxy.io. +# gateway_url=https://your-proxy.example.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 09f4099..7b84dae 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ pnpm-debug.log* *.swo # junit -junit.xml \ No newline at end of file +junit.xml + +SYSTEM_ARCHITECTURE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d712ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`@terrakernel/odxproxy-client-js` is the official JS/TS client SDK for ODXProxy — a gateway that exposes Odoo JSON-RPC over HTTPS with API-key auth. The SDK is a thin, typed, **dependency-free** wrapper that turns helper calls into a single POST to the gateway's `/api/odoo/execute` endpoint (plus a few auxiliary endpoints). There is no UI and no server here; this package is published to npm and runs both in Node (18+) and the browser. + +`SYSTEM_ARCHITECTURE.md` is the **wire-protocol source of truth** for the gateway — consult it before changing request/response shapes, actions, or error handling. + +## Commands + +```bash +npm run build # tsup → dist/ (ESM index.mjs, CJS index.js, minified, + .d.ts) +npm test # jest in CI mode, writes junit.xml via jest-junit +npm run test:watch # jest --watch +npx jest unit.test.ts # hermetic unit tests only (no creds needed) +npx jest -t "error model" # run tests by name +``` + +`prepublishOnly` runs build + test, so a green `npm test` is required before publishing. + +## Architecture + +Two source files, with a deliberate split: + +- **`src/client.ts`** — the `OdxProxyClient` **singleton**, all interfaces, and the typed error hierarchy. `init(options)` constructs the one instance and **throws if called twice**; `getInstance()` **throws if `init` was never called**. The client holds the gateway URL (default `https://gateway.odxproxy.io`, trailing slash trimmed), the proxy `x-api-key`, and the single bound Odoo instance. **There is intentionally one Odoo instance per process — this is not multi-tenant** (see the project memory; do not add `for_instance`/per-call instance overrides). + + All network I/O goes through one private `send()` method built on the **global `fetch`** (no axios) with an `AbortController` for timeouts. `send()` reads the body once, then `envelopeOrThrow()` applies the protocol's two-step error rule. + +- **`src/index.ts`** — the public API: stateless module functions over the singleton. Data helpers (`search`, `search_read`, `read`, `fields_get`, `search_count`, `create`, `write`, `remove`, `call_method`) build an `OdxClientRequest` and call `postRequest`. Auxiliary helpers (`version`, `about`, `license`, `metrics`) map to the gateway's other endpoints. The file also re-exports all public types and error classes. + +### Error model (matches SYSTEM_ARCHITECTURE §6) + +Errors are **thrown**, not returned, and are typed. `OdxError` is the base (carries `code`, `message`, `data`, `httpStatus`); subclasses map to the JSON-RPC code catalog: `AuthError` (-32000), `InvalidActionError` (-32001), `MissingFnNameError` (-32002), `OdooTimeoutError` (-32003), `OdooConnectError` (-32004), `InternalProxyError` (-32005), `LicenseError` (0/403), `OdooLogicError` (Odoo's own code on a 200). The `code` is the **JSON-RPC code** (falling back to HTTP status when there's no body error) — *not* the HTTP status. Crucially, a **200 carrying an `error` body is thrown** as `OdooLogicError`, so callers use one `try/catch` for everything. When adding a code, update `makeError()` in `client.ts`. + +### Conventions that are easy to get wrong + +- **Action name vs. function name don't always match.** `remove` sends `action: "unlink"`; `call_method` sends `action: "call_method"` plus a separate `fn_name` (and rejects client-side with `MissingFnNameError` if it's empty). `update` is a deprecated alias of `write`. +- **Keyword stripping.** All helpers *except `search_read`* strip `order`, `limit`, `offset`, `fields` (the `NON_QUERY_KEYS` constant) from the keyword before sending — those only apply to a combined search+read. The sort key is **`order`** (Odoo's `execute_kw` kwarg), not `sort`; the older `sort` naming was a bug and is gone. +- **`params` shape is positional and Odoo-specific**, varying per action: search domains are triple-nested (`[[ ["field","=",val] ]]`), `read`/`remove` wrap ID lists (`[[1,2]]`), `write` is `[[ids], {fields}]`, `create` is `[{fields}]`. Match the existing JSDoc rather than guessing. `params` is passed through unchanged (not copied). +- **Per-call options.** Every helper takes an optional trailing `opts: OdxRequestOptions` (`timeoutSecs` → `x-request-timeout` header; `signal` → cancellation). Default upstream timeout can be set once via `init({ ..., default_timeout_secs })`. +- **Isomorphic gotchas.** `crypto.randomUUID()` is unavailable in non-secure browser contexts, so `newRequestId()` falls back to `getRandomValues`. `accept-encoding` is set but browsers ignore it (forbidden header); it only takes effect on Node/undici. + +## Testing + +- **`__tests__/unit.test.ts`** — hermetic; mocks global `fetch`, needs no credentials. This is the suite to run/extend when changing transport, error mapping, header injection, or the auxiliary endpoints. +- **`__tests__/index.test.ts`** — **live integration** against a real ODXProxy/Odoo instance. Guarded by `describeLive` and **skipped unless `url` and `odx_api_key` env vars are set** (loaded from `.env` by `jest.setup.ts`, using lowercase names: `url`, `db`, `uid`, `api_key`, `odx_api_key`). These assert only `res.jsonrpc === "2.0"` and chain create→write→remove through a shared id, so they are order-dependent and fail if the backend returns any error (the new client throws on those). + +## Build / publishing notes + +- TypeScript is `strict`; `tsconfig` uses `lib: ["ES2020", "DOM"]` so `fetch`/`AbortController`/`crypto`/`Response` type without `@types/node`. tsup bundles to ESM + CJS (minified) + `.d.ts`; `package.json` `exports` maps `import`→`dist/index.mjs`, `require`→`dist/index.js`. +- **Zero runtime dependencies** and `"sideEffects": false` for clean browser tree-shaking. Runtime floor is **Node 18+** (`engines`), since global `fetch` is required. +- **Versioning tracks the proxy** — stay in `0.1.x`; breaking changes are acceptable within that range but don't jump to 0.2/1.0 ahead of the proxy (see project memory). \ No newline at end of file diff --git a/README.md b/README.md index f8d4f0b..00730bc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Official JavaScript/TypeScript client for ODXProxy. This SDK provides a simple, - Repository package name: `@terrakernel/odxproxy-client-js` - License: MIT -- Runtime: Node.js 18+ +- Runtime: Node.js 18+ (uses the global `fetch`) or any modern browser +- Dependencies: none at runtime - Language: TypeScript (bundled to ESM and CJS) ## Table of Contents @@ -36,10 +37,13 @@ ODXProxy is a gateway that securely exposes Odoo RPC functionality over HTTPS wi ## Features - Friendly, typed wrapper around ODXProxy endpoints +- **Zero runtime dependencies** — built on the platform `fetch`, runs in Node 18+ and the browser - Supports both ESM and CommonJS - Works with TypeScript out of the box (bundled type definitions) - Covers common Odoo actions: search, search_read, read, fields_get, search_count, create, write, unlink (remove), and call_method -- Request IDs are auto-generated with ULID (can be overridden) +- Auxiliary endpoints: version, about, license, metrics +- Typed errors (`OdxError` and subclasses) thrown for every failure +- Request IDs are auto-generated (UUID via `crypto.randomUUID`, can be overridden) ## Installation @@ -92,6 +96,7 @@ init({ }, odx_api_key: "your-odxproxy-gateway-api-key", // ODXProxy Gateway API key gateway_url: "https://gateway.odxproxy.io", // Optional. Default shown (trailing slash trimmed) + default_timeout_secs: 15, // Optional. Upstream Odoo timeout (sent as x-request-timeout) }); ``` @@ -105,30 +110,36 @@ Context (passed inside `keyword`) supports: "allowed_company_ids": [1] }, "fields": ["name", "email"], - "sort": "name asc", + "order": "name asc", "limit": 10, "offset": 0 } ``` -Note: For actions that do not use fields/sort/limit/offset, the client will strip those keys before sending. +Note: For actions other than `search_read`, the client strips `fields`/`order`/`limit`/`offset` before sending (they only apply to a combined search+read). The sort key is `order` (Odoo's `execute_kw` keyword). + +Every helper also accepts an optional trailing `opts` argument for per-call control: + +```ts +await search_read("res.partner", domain, keyword, /* id */ undefined, { + timeoutSecs: 60, // overrides default_timeout_secs for this call (x-request-timeout) + signal: ac.signal, // AbortController signal for cancellation +}); +``` ## API Reference -All functions return a promise resolving to: +On success, data functions resolve to the JSON-RPC envelope (failures are **thrown** as `OdxError` — see Error Handling): ``` { jsonrpc: string; // typically "2.0" - id: string; // request id (ULID by default) - result?: any; // present on success - error?: { // present on error - code: number; - message: string; - data: any; - }; + id: string; // request id (UUID by default) + result?: any; // the Odoo result } ``` +Each data function also accepts an optional trailing `opts?: OdxRequestOptions` ({ timeoutSecs?, signal? }) after `id`. + - init(options) - Initializes the singleton client. Must be called before any other function. @@ -138,15 +149,15 @@ All functions return a promise resolving to: - search_read(model, params, keyword, id?) - params: domain array - - keyword: can include fields, limit, offset, sort, and context + - keyword: can include fields, limit, offset, order, and context - returns: result?: T[] (records) - read(model, params, keyword, id?) - - params: array of record IDs wrapped (e.g., [[1,2,3]]) - - returns: result?: T + - params: record IDs, with the field list passed positionally (e.g., [[1,2,3], ["name","email"]]); fields in `keyword` are ignored for read + - returns: result?: T (array of records) - fields_get(model, keyword, id?) - - returns: result?: T[] (field metadata) + - returns: result?: T (object keyed by field name → field metadata) - search_count(model, params, keyword, id?) - returns: result?: T (count) @@ -168,19 +179,44 @@ All functions return a promise resolving to: - returns: result?: T ## Error Handling -The client normalizes errors from Axios into a consistent structure. Timeouts (default 45s) surface as: +Every failure is **thrown** as a typed error — proxy-level failures (non-2xx) *and* Odoo logic errors (an `error` body on a `200`). On success the helper resolves to the envelope with `result` set. Use a single `try/catch`: -``` -{ code: 408, message: "Request Timeout: exceeded client limit of 45000ms", data: null } +```ts +import { search_read, AuthError, OdooLogicError, OdxError } from "@terrakernel/odxproxy-client-js"; + +try { + const res = await search_read("res.partner", [[]], { context: { tz: "UTC" } }); + console.log(res.result); +} catch (err) { + if (err instanceof AuthError) { /* bad x-api-key */ } + else if (err instanceof OdooLogicError) { /* Odoo validation/access error */ } + else if (err instanceof OdxError) { console.error(err.code, err.message, err.data, err.httpStatus); } +} ``` -For other HTTP errors, you will receive: +All errors extend `OdxError` and carry the JSON-RPC `code`, `message`, `data`, and the raw `httpStatus`. Subclasses map to the proxy's error catalog: -``` -{ code: number, message: string, data: any } -``` +| Class | code | When | +|---|---|---| +| `AuthError` | -32000 | Missing/wrong `x-api-key` | +| `InvalidActionError` | -32001 | Action not allowed | +| `MissingFnNameError` | -32002 | `call_method` without `fn_name` (also raised client-side) | +| `OdooTimeoutError` | -32003 | Upstream Odoo timeout (also on client-side abort) | +| `OdooConnectError` | -32004 | Network failure reaching Odoo | +| `InternalProxyError` | -32005 | Internal proxy error | +| `LicenseError` | 0 | Proxy license expired/invalid (HTTP 403) | +| `OdooLogicError` | *Odoo's code* | Odoo-side logic error returned on a 200 | -Wrap calls in try/catch if you prefer exceptions, or check for `error` on the returned object if you treat it as a value. +## Auxiliary endpoints + +```ts +import { version, about, license, metrics } from "@terrakernel/odxproxy-client-js"; + +await version("https://your-odoo.example.com"); // Odoo version banner (no Odoo creds needed) +await about(); // { jsonrpc, id, result: { build, version } } +await license(); // { licensee, valid_until, is_valid } — flat object, not an envelope +await metrics(); // Prometheus metrics as a string +``` ## Examples - Search partners (IDs only): @@ -217,17 +253,19 @@ await call_method("account.move", [[5]], { context: { tz: "UTC" } }, "action_pos ``` ## Testing -This repo uses Jest. You can run tests locally (requires valid environment variables to hit a real ODXProxy/Odoo instance): +This repo uses Jest with two suites: + +- **Unit tests** (`__tests__/unit.test.ts`) mock `fetch` and need no credentials — run them anytime. +- **Integration tests** (`__tests__/index.test.ts`) hit a real ODXProxy/Odoo instance and are **skipped unless `url` and `odx_api_key` are set**. ``` -npm test +npm test # both suites (integration skipped without creds) +npx jest unit.test.ts # unit tests only ``` -Environment variables used in tests: +Environment variables for the integration suite (loaded from `.env` by `jest.setup.ts`): - url, db, uid, api_key, odx_api_key -You can define them in a local .env and load with dotenv in your test runner setup if desired. - ## Build Build the package (generates ESM, CJS, and type definitions): diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 5c62286..606c65f 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,22 +1,33 @@ // __tests__/index.test.ts +// Integration tests against a real ODXProxy/Odoo instance, exercising the full +// res.partner lifecycle. Skipped unless `url` and `odx_api_key` env vars are set +// (loaded from .env by jest.setup.ts). Under the current error model every failure +// throws an OdxError, so a successful call always carries `result`. import { + init, search, search_read, read, fields_get, search_count, create, - remove, write, - call_method, init, + remove, } from "../src/index"; -import {OdxProxyClientInfo} from "../src/client"; +import { OdxProxyClientInfo } from "../src/client"; + +const runLive = !!process.env.url && !!process.env.odx_api_key; +const describeLive = runLive ? describe : describe.skip; + +const ctx = { tz: "Asia/Jakarta", default_company_id: 1, allowed_company_ids: [1] }; +const PARTNER_NAME = "ODX Test Partner"; + +describeLive("res.partner lifecycle", () => { + // Shared across the ordered tests below — the record we create, mutate, then delete. + let partnerId: number; -describe("All methods test", () => { - let createdRecord: number; beforeAll(() => { - let uid = process.env.uid || 2; - uid = +uid; + const uid = +(process.env.uid || process.env.user_id || 2); const options: OdxProxyClientInfo = { instance: { db: process.env.db || "", @@ -25,138 +36,77 @@ describe("All methods test", () => { api_key: process.env.api_key || "", }, odx_api_key: process.env.odx_api_key || "", + // Defaults to https://gateway.odxproxy.io; set gateway_url for a self-hosted proxy. + ...(process.env.gateway_url ? { gateway_url: process.env.gateway_url } : {}), }; init(options); }); - it("search", async () => { - const res = await search("res.partner", [ - [ - ["is_company", "=", false] - ] - ], { context: { tz: "Asia/Jakarta", default_company_id: 1, allowed_company_ids: [1] } }); + it("create returns a numeric id", async () => { + const res = await create( + "res.partner", + [{ name: PARTNER_NAME, email: "odx-test@example.com", is_company: false }], + { context: ctx }, + ); expect(res.jsonrpc).toBe("2.0"); + expect(typeof res.result).toBe("number"); + partnerId = res.result as number; }, 30000); - it("search_read", async () => { - const res = await search_read<{id: number, name?: string, email?: string}>("res.partner", [ - [ - ["is_company", "=", false] - ] - ], { context: { tz: "Asia/Jakarta", default_company_id: 1, allowed_company_ids: [1] }, limit: 10, fields: ["name","email"] }); - expect(res.jsonrpc).toBe("2.0"); + it("read returns the created record (fields passed positionally)", async () => { + expect(partnerId).toBeDefined(); + const res = await read>( + "res.partner", + [[partnerId], ["name", "email"]], + { context: ctx }, + ); + expect(res.result?.[0]?.id).toBe(partnerId); + expect(res.result?.[0]?.name).toBe(PARTNER_NAME); }, 30000); - it("read", async () => { - const res = await read<{id: number, name?: string, email?: string}>("res.partner", [[2]], - { context: { tz: "Asia/Jakarta", default_company_id: 1, allowed_company_ids: [1] }, limit: 10, fields: ["name","email"] }); - expect(res.jsonrpc).toBe("2.0"); + it("search finds the id by domain", async () => { + const res = await search("res.partner", [[["id", "=", partnerId]]], { context: ctx }); + expect(res.result).toContain(partnerId); }, 30000); - it("fields_get", async () => { - const res = await fields_get("res.partner", - { - context: - { - tz: "Asia/Jakarta", - default_company_id: 1, - allowed_company_ids: [1] - } - }); - expect(res.jsonrpc).toBe("2.0"); + it("search_read returns matching records with selected fields", async () => { + const res = await search_read>( + "res.partner", + [[["id", "=", partnerId]]], + { context: ctx, fields: ["name"], limit: 5 }, + ); + expect(res.result?.[0]?.id).toBe(partnerId); + expect(res.result?.[0]?.name).toBe(PARTNER_NAME); }, 30000); - it("search_count", async () => { - const res = await search_count("res.partner", [ - [ - ["is_company", "=", false] - ] - ], { context: { tz: "Asia/Jakarta", default_company_id: 1, allowed_company_ids: [1] }}); - expect(res.jsonrpc).toBe("2.0"); + it("search_count counts matching records", async () => { + const res = await search_count("res.partner", [[["id", "=", partnerId]]], { context: ctx }); + expect(res.result).toBe(1); }, 30000); - it("create", async () => { - const res = await create("res.partner", [{ name: "Acme" }], { context: { tz: "UTC" } }); - expect(res.jsonrpc).toBe("2.0"); - createdRecord = res.result; - }, 30000); - - it("write", async () => { - if (createdRecord) { - const res = await write("res.partner", [[createdRecord], {name: "ACME Updated"}], {context: {tz: "Asia/Jakarta"}}) - expect(res.jsonrpc).toBe("2.0"); - } else { - expect(true).toBe(false); - } + it("fields_get describes the model's fields", async () => { + const res = await fields_get("res.partner", { context: ctx }); + expect(res.result).toHaveProperty("name"); + expect(res.result).toHaveProperty("email"); }, 30000); - it("remove", async () => { - if (createdRecord) { - const res = await remove("res.partner", [[createdRecord]], {context: {tz: "Asia/Jakarta"}}) - expect(res.jsonrpc).toBe("2.0"); - } else { - expect(true).toBe(false); - } - }, 30000); + it("write updates the record", async () => { + const updated = await write( + "res.partner", + [[partnerId], { name: `${PARTNER_NAME} (updated)` }], + { context: ctx }, + ); + expect(updated.result).toBe(true); - it("call_method", async () => { - const res = await call_method("account.move", [[5]], {context: {tz: "Asia/Jakarta"}}, "action_post") - expect(res.jsonrpc).toBe("2.0"); + const after = await read>("res.partner", [[partnerId], ["name"]], { context: ctx }); + expect(after.result?.[0]?.name).toBe(`${PARTNER_NAME} (updated)`); }, 30000); - // it("search_read should call postRequest with action 'search_read'", async () => { - // await search_read("res.partner", [], { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "search_read" }) - // ); - // }); - // - // it("read should call postRequest with action 'read'", async () => { - // await read("res.partner", [2], { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "read" }) - // ); - // }); - // - // it("fields_get should call postRequest with action 'fields_get'", async () => { - // await fields_get("res.partner", { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "fields_get" }) - // ); - // }); - // - // it("search_count should call postRequest with action 'search_count'", async () => { - // await search_count("res.partner", [[]], { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "search_count" }) - // ); - // }); - // - // it("create should call postRequest with action 'create'", async () => { - // await create("res.partner", [{ name: "Acme" }], { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "create" }) - // ); - // }); - - // it("remove should call postRequest with action 'unlink'", async () => { - // await remove("res.partner", [1], { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "unlink" }) - // ); - // }); + it("remove deletes the record", async () => { + const removed = await remove("res.partner", [[partnerId]], { context: ctx }); + expect(removed.result).toBe(true); - // it("update should call postRequest with action 'update'", async () => { - // await update("res.partner", [[1], { name: "Updated" }], { context: { tz: "UTC" } }); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "update" }) - // ); - // }); - - // it("call_method should call postRequest with action 'call_method'", async () => { - // await call_method("res.partner", [1], { context: { tz: "UTC" } }, "my_method"); - // expect(clientMock.postRequest).toHaveBeenCalledWith( - // expect.objectContaining({ action: "call_method", fn_name: "my_method" }) - // ); - // }); + const count = await search_count("res.partner", [[["id", "=", partnerId]]], { context: ctx }); + expect(count.result).toBe(0); + }, 30000); }); \ No newline at end of file diff --git a/__tests__/unit.test.ts b/__tests__/unit.test.ts new file mode 100644 index 0000000..2bdff7b --- /dev/null +++ b/__tests__/unit.test.ts @@ -0,0 +1,160 @@ +// __tests__/unit.test.ts +// Pure unit tests — no live ODXProxy/Odoo instance required. `fetch` is mocked. +import { + init, + search, + search_read, + call_method, + version, + license, + about, + metrics, + AuthError, + InvalidActionError, + MissingFnNameError, + OdooTimeoutError, + OdooConnectError, + OdooLogicError, + OdxError, + type OdxProxyClientInfo, +} from "../src/index"; + +type FetchArgs = { url: string; init: RequestInit }; + +let lastCall: FetchArgs | undefined; +const fetchMock = jest.fn(); + +function jsonResponse(body: any, status = 200): Response { + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + statusText: status === 200 ? "OK" : "Error", + headers: { "content-type": "application/json" }, + }); +} + +beforeAll(() => { + (globalThis as any).fetch = (url: string, init: RequestInit) => { + lastCall = { url, init }; + return fetchMock(url, init); + }; + const options: OdxProxyClientInfo = { + instance: { db: "db", user_id: 2, url: "https://erp.example.com", api_key: "odoo-key" }, + odx_api_key: "proxy-key", + gateway_url: "https://gw.example.com/", + default_timeout_secs: 15, + }; + init(options); +}); + +beforeEach(() => { + lastCall = undefined; + fetchMock.mockReset(); +}); + +describe("request shaping", () => { + it("posts to /api/odoo/execute with the right action, headers, and body", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "x", result: [1, 2] })); + const res = await search("res.partner", [[["is_company", "=", false]]], { context: { tz: "UTC" } }); + + expect(res.result).toEqual([1, 2]); + expect(lastCall!.url).toBe("https://gw.example.com/api/odoo/execute"); + expect(lastCall!.init.method).toBe("POST"); + const headers = lastCall!.init.headers as Record; + expect(headers["x-api-key"]).toBe("proxy-key"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers["x-request-timeout"]).toBe("15"); // from default_timeout_secs + const body = JSON.parse(lastCall!.init.body as string); + expect(body.action).toBe("search"); + expect(body.odoo_instance.db).toBe("db"); + expect(typeof body.id).toBe("string"); + }); + + it("strips order/limit/offset/fields for non-search_read actions", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "x", result: 3 })); + await search("res.partner", [[]], { context: { tz: "UTC" }, limit: 10, fields: ["name"], order: "name asc" }); + const body = JSON.parse(lastCall!.init.body as string); + expect(body.keyword).toEqual({ context: { tz: "UTC" } }); + }); + + it("keeps order/limit/offset/fields for search_read", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "x", result: [] })); + await search_read("res.partner", [[]], { context: { tz: "UTC" }, limit: 10, fields: ["name"] }); + const body = JSON.parse(lastCall!.init.body as string); + expect(body.keyword.limit).toBe(10); + expect(body.keyword.fields).toEqual(["name"]); + }); + + it("honors a per-call timeout override", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "x", result: 0 })); + await search("res.partner", [[]], { context: { tz: "UTC" } }, undefined, { timeoutSecs: 60 }); + const headers = lastCall!.init.headers as Record; + expect(headers["x-request-timeout"]).toBe("60"); + }); +}); + +describe("error model", () => { + const cases: [number, number, new (...a: any[]) => OdxError][] = [ + [401, -32000, AuthError], + [400, -32001, InvalidActionError], + [504, -32003, OdooTimeoutError], + [502, -32004, OdooConnectError], + ]; + + it.each(cases)("maps HTTP %s / code %s to the right typed error", async (status, code, Ctor) => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: null, error: { code, message: "boom", data: { x: 1 } } }, status)); + await expect(search("res.partner", [[]], { context: { tz: "UTC" } })).rejects.toBeInstanceOf(Ctor); + }); + + it("throws OdooLogicError on a 200 carrying an error body (two-step rule)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "x", error: { code: 200, message: "ValidationError", data: null } }, 200)); + await expect(search("res.partner", [[]], { context: { tz: "UTC" } })).rejects.toBeInstanceOf(OdooLogicError); + }); + + it("preserves code/message/data on the thrown error", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "nope", data: { y: 2 } } }, 401)); + await expect(search("res.partner", [[]], { context: { tz: "UTC" } })).rejects.toMatchObject({ code: -32000, message: "nope", data: { y: 2 }, httpStatus: 401 }); + }); + + it("maps a network failure to OdooConnectError", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + await expect(search("res.partner", [[]], { context: { tz: "UTC" } })).rejects.toBeInstanceOf(OdooConnectError); + }); + + it("rejects call_method with empty fn_name client-side", async () => { + await expect(call_method("account.move", [[5]], { context: { tz: "UTC" } }, "")).rejects.toBeInstanceOf(MissingFnNameError); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("auxiliary endpoints", () => { + it("version posts to /api/odoo/version with url + id, no odoo creds", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "x", result: { server_version: "17.0" } })); + await version("https://erp.example.com"); + expect(lastCall!.url).toBe("https://gw.example.com/api/odoo/version"); + const body = JSON.parse(lastCall!.init.body as string); + expect(body.url).toBe("https://erp.example.com"); + expect(body.odoo_instance).toBeUndefined(); + }); + + it("license GETs /_/license without an api key and returns the flat object", async () => { + fetchMock.mockResolvedValue(jsonResponse({ licensee: "ACME", valid_until: "2099-01-01", is_valid: true })); + const lic = await license(); + expect(lic.is_valid).toBe(true); + expect(lastCall!.url).toBe("https://gw.example.com/_/license"); + expect(lastCall!.init.method).toBe("GET"); + const headers = lastCall!.init.headers as Record; + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("about returns the envelope", async () => { + fetchMock.mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "about", result: { build: "01H", version: "0.1.7" } })); + const res = await about(); + expect(res.result.version).toBe("0.1.7"); + }); + + it("metrics returns raw text", async () => { + fetchMock.mockResolvedValue(new Response("odoo_success_response 5\n", { status: 200, statusText: "OK" })); + const text = await metrics(); + expect(text).toContain("odoo_success_response"); + }); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index de83bbe..3ccf297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,13 @@ { "name": "@terrakernel/odxproxy-client-js", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@terrakernel/odxproxy-client-js", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", - "dependencies": { - "axios": "^1.13.5", - "ulid": "^3.0.2" - }, "devDependencies": { "@types/jest": "^30.0.0", "dotenv": "^17.2.2", @@ -20,6 +16,9 @@ "ts-jest": "^29.4.1", "tsup": "^8.0.1", "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@babel/code-frame": { @@ -2289,23 +2288,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", @@ -2526,19 +2508,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2762,18 +2731,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2873,15 +2830,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2905,20 +2853,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2963,51 +2897,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -3199,26 +3088,6 @@ "rollup": "^4.34.8" } }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3236,22 +3105,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3274,15 +3127,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3303,30 +3147,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3337,19 +3157,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3384,18 +3191,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3435,45 +3230,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4539,15 +4295,6 @@ "tmpl": "1.0.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4569,27 +4316,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5052,12 +4778,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5901,15 +5621,6 @@ "node": ">=0.8.0" } }, - "node_modules/ulid": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", - "integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==", - "license": "MIT", - "bin": { - "ulid": "dist/cli.js" - } - }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", diff --git a/package.json b/package.json index f2d6ca4..f9a5305 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,14 @@ { "name": "@terrakernel/odxproxy-client-js", - "version": "0.1.6", + "version": "0.1.7", "description": "Official JavaScript/TypeScript client for ODXProxy", "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "sideEffects": false, + "engines": { + "node": ">=18" + }, "exports": { ".": { "import": "./dist/index.mjs", @@ -12,7 +16,7 @@ } }, "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts", + "build": "tsup src/index.ts --format esm,cjs --dts --minify", "test": "jest --ci --reporters=default --reporters=jest-junit", "test:watch": "jest --watch", "prepublishOnly": "npm run build && npm test" @@ -48,8 +52,5 @@ "tsup": "^8.0.1", "typescript": "^5.5.0" }, - "dependencies": { - "axios": "^1.13.5", - "ulid": "^3.0.2" - } + "dependencies": {} } diff --git a/src/client.ts b/src/client.ts index f542e6d..2084853 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,7 +3,6 @@ * Copyright (c) 2025 TERRAKERNEL PTE. LTD. * Author Julian Richie Wajong */ -import axios, {AxiosInstance} from "axios"; export interface OdxInstanceInfo{ url: string; @@ -16,6 +15,8 @@ export interface OdxProxyClientInfo{ instance: OdxInstanceInfo; odx_api_key: string gateway_url?: string; + /** Default upstream Odoo timeout in seconds, sent as the `x-request-timeout` header. */ + default_timeout_secs?: number; } export interface OdxClientRequestContext{ @@ -55,28 +56,115 @@ export interface OdxClientRequest { odoo_instance: OdxInstanceInfo } +/** Flat object returned by `GET /_/license` (NOT a JSON-RPC envelope). */ +export interface OdxLicenseInfo { + licensee: string; + valid_until: string; + is_valid: boolean; +} + +/** Per-call options accepted by every request method. */ +export interface OdxRequestOptions { + /** Override the upstream Odoo timeout (seconds) for this call via `x-request-timeout`. */ + timeoutSecs?: number; + /** Caller-supplied AbortSignal to cancel the request. */ + signal?: AbortSignal; +} + +/** + * Base class for every error thrown by the client. Carries the JSON-RPC `code`, + * `message`, and `data` from the proxy (per SYSTEM_ARCHITECTURE §6), plus the raw + * HTTP status. Use `instanceof` on the subclasses below to branch on error type. + */ +export class OdxError extends Error { + readonly code: number; + readonly data: any; + readonly httpStatus: number; + + constructor(code: number, message: string, data: any, httpStatus: number) { + super(message); + this.name = new.target.name; + this.code = code; + this.data = data; + this.httpStatus = httpStatus; + // Restore prototype chain so `instanceof` works after transpilation to ES5/ES2020. + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** -32000 / 401 — missing or wrong `x-api-key`. */ +export class AuthError extends OdxError {} +/** -32001 / 400 — `action` not in the allowlist. */ +export class InvalidActionError extends OdxError {} +/** -32002 / 400 — `call_method` without a non-empty `fn_name`. */ +export class MissingFnNameError extends OdxError {} +/** -32003 / 504 — upstream Odoo call timed out (also raised on client-side abort). */ +export class OdooTimeoutError extends OdxError {} +/** -32004 / 502 — network failure connecting to the Odoo instance. */ +export class OdooConnectError extends OdxError {} +/** -32005 / 500 — internal proxy error decoding the Odoo response. */ +export class InternalProxyError extends OdxError {} +/** 0 / 403 — the proxy's license is expired or invalid. */ +export class LicenseError extends OdxError {} +/** Odoo's own error code / 200 — an Odoo-side logic error (validation, access, etc.). */ +export class OdooLogicError extends OdxError {} + +/** Maps a (jsonrpc code, http status) pair to the appropriate typed error. */ +function makeError(code: number, message: string, data: any, httpStatus: number): OdxError { + switch (code) { + case -32000: return new AuthError(code, message, data, httpStatus); + case -32001: return new InvalidActionError(code, message, data, httpStatus); + case -32002: return new MissingFnNameError(code, message, data, httpStatus); + case -32003: return new OdooTimeoutError(code, message, data, httpStatus); + case -32004: return new OdooConnectError(code, message, data, httpStatus); + case -32005: return new InternalProxyError(code, message, data, httpStatus); + case 0: + if (httpStatus === 403) return new LicenseError(code, message, data, httpStatus); + break; + } + // A 200 carrying an error object is always an Odoo-side logic error (passthrough code). + if (httpStatus === 200) return new OdooLogicError(code, message, data, httpStatus); + return new OdxError(code, message, data, httpStatus); +} + +/** + * Generates a request id. Prefers `crypto.randomUUID()` (browsers in a secure + * context, Node 18+), falls back to `getRandomValues`, then to a timestamp — so it + * works in plain-HTTP browser dev contexts where `randomUUID` is unavailable. + */ +export function newRequestId(): string { + const c: any = (globalThis as any).crypto; + if (c?.randomUUID) return c.randomUUID(); + if (c?.getRandomValues) { + const b: Uint8Array = c.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")); + return `${h[0]}${h[1]}${h[2]}${h[3]}-${h[4]}${h[5]}-${h[6]}${h[7]}-${h[8]}${h[9]}-${h[10]}${h[11]}${h[12]}${h[13]}${h[14]}${h[15]}`; + } + return "id-" + Date.now().toString(16) + "-" + Math.random().toString(16).slice(2); +} + +const DEFAULT_GATEWAY_URL = "https://gateway.odxproxy.io"; +/** Client-side abort ceiling (ms) when no timeout is configured — matches legacy axios behavior. */ +const DEFAULT_CLIENT_TIMEOUT_MS = 45000; + export class OdxProxyClient { private static instance: OdxProxyClient | null = null; - private api: AxiosInstance; private OdooInstance: OdxInstanceInfo; private gatewayUrl: string; + private apiKey: string; + private defaultTimeoutSecs?: number; private constructor(options: OdxProxyClientInfo) { this.OdooInstance = options.instance; - let gatewayUrl = options.gateway_url ?? "https://gateway.odxproxy.io"; + let gatewayUrl = options.gateway_url ?? DEFAULT_GATEWAY_URL; if (gatewayUrl.endsWith("/")) { gatewayUrl = gatewayUrl.slice(0, -1); } this.gatewayUrl = gatewayUrl; - this.api = axios.create({ - baseURL: this.gatewayUrl, - headers: { - "accept": "application/json", - "content-type": "application/json", - "x-api-key": options.odx_api_key - }, - timeout: 45000 - }) + this.apiKey = options.odx_api_key; + this.defaultTimeoutSecs = options.default_timeout_secs; } static init(options: OdxProxyClientInfo){ @@ -94,34 +182,139 @@ export class OdxProxyClient { return OdxProxyClient.instance; } - async postRequest(request: OdxClientRequest):Promise { + /** + * Main RPC entry point — `POST /api/odoo/execute`. + * Throws a typed {@link OdxError} on any proxy-level failure (non-2xx) or Odoo + * logic error (200 with an `error` body), per SYSTEM_ARCHITECTURE §6. + */ + async postRequest(request: OdxClientRequest, opts?: OdxRequestOptions): Promise { + const { res, raw } = await this.send("POST", "/api/odoo/execute", request, true, opts); + return this.envelopeOrThrow(res, raw) as OdxServerResponse & {result?: T}; + } + + /** + * Queries the target Odoo instance's version banner — `POST /api/odoo/version`. + * Requires no Odoo credentials (the upstream endpoint is public). + */ + async version(url: string, id?: string, opts?: OdxRequestOptions): Promise { + const reqId = id || newRequestId(); + const { res, raw } = await this.send("POST", "/api/odoo/version", { id: reqId, url }, true, opts); + return this.envelopeOrThrow(res, raw) as OdxServerResponse & {result?: T}; + } + + /** Build info — `GET /_/about`. No API key required. */ + async about(opts?: OdxRequestOptions): Promise { + const { res, raw } = await this.send("GET", "/_/about", undefined, false, opts); + return this.envelopeOrThrow(res, raw); + } + + /** License info — `GET /_/license`. No API key required. Returns a flat object, not an envelope. */ + async license(opts?: OdxRequestOptions): Promise { + const { res, raw } = await this.send("GET", "/_/license", undefined, false, opts); + if (!res.ok) { + throw makeError(res.status, res.statusText || "Request failed", raw ?? null, res.status); + } + return raw as OdxLicenseInfo; + } + + /** Prometheus metrics — `GET /_/metrics`. No API key required. Returns raw text. */ + async metrics(opts?: OdxRequestOptions): Promise { + const { res, raw } = await this.send("GET", "/_/metrics", undefined, false, opts); + if (!res.ok) { + throw makeError(res.status, res.statusText || "Request failed", raw ?? null, res.status); + } + return typeof raw === "string" ? raw : JSON.stringify(raw); + } + + getOdooInstance(): OdxInstanceInfo { + return this.OdooInstance; + } + + /** Performs the fetch with timeout/abort handling and reads the body once. */ + private async send( + method: string, + path: string, + body: any | undefined, + withApiKey: boolean, + opts?: OdxRequestOptions, + ): Promise<{ res: Response; raw: any }> { + const headers = this.buildHeaders(withApiKey, body !== undefined, opts); + const init: RequestInit = { method, headers }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + const controller = new AbortController(); + let didTimeout = false; + const secs = opts?.timeoutSecs ?? this.defaultTimeoutSecs; + // Give the proxy a 5s margin past its own upstream timeout so it can return -32003. + const ceilingMs = (typeof secs === "number" && secs > 0) ? secs * 1000 + 5000 : DEFAULT_CLIENT_TIMEOUT_MS; + const timer = setTimeout(() => { didTimeout = true; controller.abort(); }, ceilingMs); + const onExternalAbort = () => controller.abort(); + if (opts?.signal) { + if (opts.signal.aborted) controller.abort(); + else opts.signal.addEventListener("abort", onExternalAbort, { once: true }); + } + try { - const res = await this.api.request( - { - url: this.gatewayUrl + "/api/odoo/execute", - method: "POST", - data: request, - }); - return res.data; + const res = await fetch(this.gatewayUrl + path, { ...init, signal: controller.signal }); + const raw = await this.readBody(res); + return { res, raw }; } catch (err: any) { - if (err.code === "ECONNABORTED") { - throw { - code: 408, - message: "Request Timeout: exceeded client limit of " + this.api.defaults.timeout + "ms", - data: null, - } as OdxServerErrorResponse; + if (err?.name === "AbortError") { + if (didTimeout) { + throw new OdooTimeoutError(-32003, "Request Timeout: exceeded client limit of " + ceilingMs + "ms", null, 0); + } + throw err; // caller-initiated abort — propagate as-is } - const status = err.response?.status ?? 500; - const rawData = err.response?.data; - throw { - code: status, - message: err.response?.statusText ?? err.message, - data: typeof rawData === "string" ? {text: rawData} : rawData, - } as OdxServerErrorResponse; + throw new OdooConnectError(-32004, err?.message ?? "Network request failed", null, 0); + } finally { + clearTimeout(timer); + if (opts?.signal) opts.signal.removeEventListener("abort", onExternalAbort); } } - getOdooInstance(): OdxInstanceInfo { - return {...this.OdooInstance}; + private buildHeaders(withApiKey: boolean, hasBody: boolean, opts?: OdxRequestOptions): Record { + const headers: Record = { + "accept": "application/json", + // Ignored by browsers (forbidden header) but enables br/gzip/deflate on Node/undici. + "accept-encoding": "br, gzip, deflate", + }; + if (hasBody) headers["content-type"] = "application/json"; + if (withApiKey) headers["x-api-key"] = this.apiKey; + const secs = opts?.timeoutSecs ?? this.defaultTimeoutSecs; + if (typeof secs === "number" && secs > 0) headers["x-request-timeout"] = String(Math.floor(secs)); + return headers; + } + + /** Reads the response body once, parsing JSON when possible and falling back to text. */ + private async readBody(res: Response): Promise { + const text = await res.text(); + if (!text) return undefined; + try { return JSON.parse(text); } catch { return text; } + } + + /** + * Applies the SYSTEM_ARCHITECTURE §6 two-step rule: non-2xx is a proxy-level + * failure; a 200 carrying `error` is an Odoo logic error. Either way, throw a + * typed error; otherwise return the envelope. + */ + private envelopeOrThrow(res: Response, raw: any): OdxServerResponse { + const errObj: OdxServerErrorResponse | undefined = + (raw && typeof raw === "object") ? raw.error : undefined; + + if (!res.ok) { + const code = typeof errObj?.code === "number" ? errObj.code : res.status; + const message = errObj?.message ?? res.statusText ?? "Request failed"; + const data = errObj?.data ?? (typeof raw === "string" ? { text: raw } : raw ?? null); + throw makeError(code, message, data, res.status); + } + + if (errObj) { + const code = typeof errObj.code === "number" ? errObj.code : 200; + throw makeError(code, errObj.message ?? "Odoo error", errObj.data ?? null, 200); + } + + return raw as OdxServerResponse; } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f2d8d4d..9b757dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,36 +3,80 @@ * Copyright (c) 2025 TERRAKERNEL PTE. LTD. * Author Julian Richie Wajong */ -import {OdxProxyClient, OdxProxyClientInfo, OdxServerResponse, OdxClientRequest, OdxClientKeywordRequest} from "./client"; -import { ulid } from "ulid"; +import { + OdxProxyClient, + OdxProxyClientInfo, + OdxServerResponse, + OdxClientRequest, + OdxClientKeywordRequest, + OdxRequestOptions, + OdxLicenseInfo, + MissingFnNameError, + newRequestId, +} from "./client"; + +// Re-export the public surface so consumers can import types and error classes from the root. +export type { + OdxInstanceInfo, + OdxProxyClientInfo, + OdxServerResponse, + OdxServerErrorResponse, + OdxClientRequest, + OdxClientRequestContext, + OdxClientKeywordRequest, + OdxRequestOptions, + OdxLicenseInfo, +} from "./client"; +export { + OdxProxyClient, + OdxError, + AuthError, + InvalidActionError, + MissingFnNameError, + OdooTimeoutError, + OdooConnectError, + InternalProxyError, + LicenseError, + OdooLogicError, + newRequestId, +} from "./client"; + export const init = (options: OdxProxyClientInfo) => OdxProxyClient.init(options); const client = () => OdxProxyClient.getInstance(); +/** Keyword keys that only apply to `search_read`; stripped from every other action's request. */ +const NON_QUERY_KEYS: (keyof OdxClientKeywordRequest)[] = ["order", "limit", "offset", "fields"]; + +/** Returns a shallow copy of `keyword` with the search-read-only keys removed. */ +const stripQueryKeys = (keyword: OdxClientKeywordRequest): OdxClientKeywordRequest => { + const k = { ...keyword }; + for (const key of NON_QUERY_KEYS) { + delete k[key]; + } + return k; +}; + /** * Performs a search on the specified Odoo model using the given domain/filter. * @template T * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - The search domain/filter as an array of conditions. -* @param {OdxClientKeywordRequest} keyword - Additional keyword arguments for the search (sort, limit, offset, fields, etc). -* @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. +* @param {OdxClientKeywordRequest} keyword - Additional keyword arguments for the search (order, limit, offset, fields, etc). +* @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. +* @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with a result array of records. **/ -export const search = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const kCopy = {...keyword}; - const paramsCopy = [...params]; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - let body: OdxClientRequest = { - id: id || ulid(), +export const search = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "search", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } /** @@ -40,22 +84,21 @@ export const search = ( model: string, params: any[], keyword: OdxCl * @template T * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - The search domain/filter as an array of conditions. - * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments (sort, limit, offset, fields, etc). - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments (order, limit, offset, fields, etc). + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with a result array of records. */ -export const search_read = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const kCopy = {...keyword}; - const paramsCopy = [...params]; - let body: OdxClientRequest = { - id: id || ulid(), +export const search_read = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "search_read", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: { ...keyword }, + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -65,24 +108,20 @@ export const search_read = ( model: string, params: any[], keyword: Odx * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - Array of record IDs to read. * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with the record(s) data. */ -export const read = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const kCopy = {...keyword}; - const paramsCopy = [...params]; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - let body: OdxClientRequest = { - id: id || ulid(), +export const read = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "read", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -91,23 +130,20 @@ export const read = ( model: string, params: any[], keyword: OdxClientK * @template T * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments for fields_get. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with field metadata. */ -export const fields_get = ( model: string, keyword: OdxClientKeywordRequest, id?: string): Promise => { - const kCopy = {...keyword}; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - let body: OdxClientRequest = { - id: id || ulid(), +export const fields_get = ( model: string, keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "fields_get", model_id: model, - keyword: kCopy, + keyword: stripQueryKeys(keyword), params: [], odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -117,25 +153,20 @@ export const fields_get = ( model: string, keyword: OdxClientKeywordReq * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - The search domain/filter as an array of conditions. * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with the count result. */ -export const search_count = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const kCopy = {...keyword}; - const paramsCopy = [...params]; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - - let body: OdxClientRequest = { - id: id || ulid(), +export const search_count = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "search_count", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -145,24 +176,20 @@ export const search_count = ( model: string, params: any[], keyword: * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - Array with a dictionary of field values for the new record. * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with the ID of the created record. */ -export const create = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const paramsCopy = [...params]; - const kCopy = {...keyword}; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - let body: OdxClientRequest = { - id: id || ulid(), +export const create = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "create", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -172,24 +199,20 @@ export const create = ( model: string, params: any[], keyword: OdxClien * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - Array of record IDs to delete. * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response indicating success. */ -export const remove = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const paramsCopy = [...params]; - const kCopy = {...keyword}; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - let body: OdxClientRequest = { - id: id || ulid(), +export const remove = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "unlink", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -199,24 +222,20 @@ export const remove = ( model: string, params: any[], keyword: OdxClien * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - Array: first element is a list of record IDs, second is a dictionary of fields to update. * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response indicating update result. */ -export const write = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string): Promise => { - const paramsCopy = [...params]; - const kCopy = {...keyword}; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; - } - let body: OdxClientRequest = { - id: id || ulid(), +export const write = ( model: string, params: any[], keyword: OdxClientKeywordRequest, id?: string, opts?: OdxRequestOptions): Promise => { + const body: OdxClientRequest = { + id: id || newRequestId(), action: "write", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) + return client().postRequest(body, opts); } @@ -232,24 +251,54 @@ export const update = write * @param {string} model - The Odoo model name in dot notation (e.g. 'res.partner'). * @param {any[]} params - Array of parameters for the method call. * @param {OdxClientKeywordRequest} keyword - Additional keyword arguments. - * @param {string} function_name - The name of the Odoo method to call. - * @param {string} [id] - Optional request ID; if not provided, a ULID will be generated. + * @param {string} function_name - The name of the Odoo method to call. Must be non-empty. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). * @returns {Promise} Promise resolving to the Odoo server response with the method result. + * @throws {MissingFnNameError} If `function_name` is missing or empty (mirrors proxy error -32002). */ -export const call_method = ( model: string, params: any[], keyword: OdxClientKeywordRequest, function_name: string, id?: string): Promise => { - const paramsCopy = [...params]; - const kCopy = {...keyword}; - for (const key of ["sort", "limit", "offset", "fields"]) { - delete kCopy[key as keyof OdxClientKeywordRequest]; +export const call_method = ( model: string, params: any[], keyword: OdxClientKeywordRequest, function_name: string, id?: string, opts?: OdxRequestOptions): Promise => { + if (!function_name) { + return Promise.reject(new MissingFnNameError(-32002, "call_method requires a non-empty fn_name", null, 400)); } - let body: OdxClientRequest = { - id: id || ulid(), + const body: OdxClientRequest = { + id: id || newRequestId(), action: "call_method", model_id: model, - keyword: kCopy, - params: paramsCopy, + keyword: stripQueryKeys(keyword), + params, fn_name: function_name, odoo_instance: client().getOdooInstance() }; - return client().postRequest(body) -} \ No newline at end of file + return client().postRequest(body, opts); +} + + +/** + * Queries the target Odoo instance's version banner (no Odoo credentials required). + * @template T + * @param {string} url - Base URL of the Odoo server to query. + * @param {string} [id] - Optional request ID; if not provided, a UUID will be generated. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). + * @returns {Promise} Promise resolving to the Odoo version_info envelope. + */ +export const version = (url: string, id?: string, opts?: OdxRequestOptions): Promise => + client().version(url, id, opts); + +/** + * Returns the running proxy build's identifiers (`GET /_/about`). No API key required. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). + */ +export const about = (opts?: OdxRequestOptions): Promise => client().about(opts); + +/** + * Returns the proxy's license info (`GET /_/license`). No API key required. Flat object, not an envelope. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). + */ +export const license = (opts?: OdxRequestOptions): Promise => client().license(opts); + +/** + * Returns the proxy's Prometheus metrics as text (`GET /_/metrics`). No API key required. + * @param {OdxRequestOptions} [opts] - Optional per-call options (timeoutSecs, signal). + */ +export const metrics = (opts?: OdxRequestOptions): Promise => client().metrics(opts); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c507955..fdaa0fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", + "lib": ["ES2020", "DOM"], "declaration": true, "declarationDir": "dist", "outDir": "dist",