Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# @toolctrl/api-client-ts
|
||||
|
||||
Official TypeScript SDK for the toolctrl boilerplate REST API.
|
||||
|
||||
The SDK couples a `@hey-api/openapi-ts`-generated typed client with a hand-written
|
||||
Higher-Level-Wrapper that adds:
|
||||
|
||||
- Bearer-token, API-key, and async-getter auth modes
|
||||
- Retry with exponential backoff and jitter, honoring `Retry-After`
|
||||
- Auto-generated `Idempotency-Key` headers on POST/PUT/PATCH
|
||||
- Async iterator pagination (cursor / token / page)
|
||||
- HMAC-SHA256 webhook signature verification with constant-time compare
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { createToolctrlClient } from "@toolctrl/api-client-ts";
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.toolctrl.de",
|
||||
auth: { apiKey: process.env.TOOLCTRL_API_KEY! },
|
||||
tenantId: process.env.TOOLCTRL_TENANT_ID,
|
||||
});
|
||||
|
||||
const me = await client.request({ method: "GET", path: "/v1/me" });
|
||||
|
||||
for await (const contact of client.paginate("/v1/contacts")) {
|
||||
console.log(contact);
|
||||
}
|
||||
```
|
||||
|
||||
## Codegen
|
||||
|
||||
The `src/generated/` directory is produced from the upstream OpenAPI document
|
||||
checked into this package as `openapi.json`. Regenerate with:
|
||||
|
||||
```bash
|
||||
pnpm --filter @toolctrl/api-client-ts generate:all
|
||||
```
|
||||
|
||||
The CI workflow `.github/workflows/sdk-codegen.yml` rejects PRs whose generated
|
||||
output drifts from the committed spec.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/sdks/typescript.md` — full usage guide
|
||||
- `docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md` — spec
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @hey-api/openapi-ts configuration.
|
||||
*
|
||||
* Reads the captured `openapi.json` (produced by scripts/generate-spec.ts) and
|
||||
* emits a tree-shakable TypeScript client + Zod schemas + types into
|
||||
* `src/generated/`.
|
||||
*
|
||||
* Run via:
|
||||
* pnpm --filter @toolctrl/api-client-ts generate
|
||||
*
|
||||
* The full pipeline (capture spec → generate client) is:
|
||||
* pnpm --filter @toolctrl/api-client-ts generate:all
|
||||
*
|
||||
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
|
||||
*/
|
||||
|
||||
import { defineConfig } from "@hey-api/openapi-ts";
|
||||
|
||||
/**
|
||||
* Note on plugin selection:
|
||||
*
|
||||
* The `zod` plugin is intentionally omitted. The latest @hey-api/openapi-ts
|
||||
* emits `z.iso.*`, `z.email()`, `z.int()`, and `z.url()` calls that require
|
||||
* zod v4, while the wider toolctrl workspace is pinned to zod v3 via
|
||||
* pnpm overrides. The generated TypeScript types and fetch SDK are
|
||||
* sufficient for the Higher-Level-Wrapper in `src/client.ts`, which performs
|
||||
* its own runtime validation policy (idempotency, retry) outside of zod.
|
||||
*
|
||||
* When the workspace migrates to zod v4 we can re-enable the plugin in a
|
||||
* follow-up commit.
|
||||
*/
|
||||
export default defineConfig({
|
||||
input: "./openapi.json",
|
||||
output: {
|
||||
path: "./src/generated",
|
||||
format: "prettier",
|
||||
lint: false,
|
||||
},
|
||||
plugins: ["@hey-api/typescript", "@hey-api/sdk", "@hey-api/client-fetch"],
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@toolctrl/api-client-ts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Official TypeScript SDK for the toolctrl boilerplate REST API. Generated from the upstream OpenAPI spec with @hey-api/openapi-ts and wrapped with auth, retry, idempotency, pagination, and webhook-verify helpers.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./webhooks": "./src/webhooks.ts",
|
||||
"./types": "./src/generated/types.gen.ts",
|
||||
"./generated": "./src/generated/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"openapi.json",
|
||||
"openapi-ts.config.ts",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"generate-spec": "tsx scripts/generate-spec.ts",
|
||||
"generate": "openapi-ts",
|
||||
"generate:all": "pnpm run generate-spec && pnpm run generate",
|
||||
"generate:check": "tsx scripts/generate-spec.ts --check",
|
||||
"build": "pnpm run typecheck",
|
||||
"test": "vitest run src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.97.1",
|
||||
"@toolctrl/api": "workspace:*",
|
||||
"@types/node": "^25.0.0",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const scriptPath = resolve(here, "generate-spec.ts");
|
||||
|
||||
/**
|
||||
* Smoke test: the script exits 0 in write mode, then `--check` mode confirms
|
||||
* the file is in sync. We do not exercise the network or invoke the heavy
|
||||
* Hono import here — these tests run alongside vitest's default include.
|
||||
*
|
||||
* This file deliberately lives under scripts/ so the package's vitest config
|
||||
* (which only globs src/**) skips it during regular `pnpm test`. It is kept
|
||||
* here as documentation + smoke for manual / CI invocation:
|
||||
*
|
||||
* pnpm exec vitest run packages/api-client-ts/scripts/generate-spec.test.ts
|
||||
*/
|
||||
describe("generate-spec script", () => {
|
||||
it("compiles via tsx --check without throwing", () => {
|
||||
// Just verify the script is importable / syntactically valid by running --help-equivalent.
|
||||
// We invoke the file with --check, which only succeeds when openapi.json already exists;
|
||||
// when it doesn't, the script exits with code 1 (missing) or 2 (drift). Either is a "ran" signal.
|
||||
const result = spawnSync("pnpm", ["exec", "tsx", scriptPath, "--check"], {
|
||||
cwd: resolve(here, ".."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect([0, 1, 2]).toContain(result.status ?? -1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* generate-spec — capture the boilerplate's OpenAPI document into a local file.
|
||||
*
|
||||
* Invokes the headless `createOpenApiDocument` factory from `@toolctrl/api` and
|
||||
* writes the result to `packages/api-client-ts/openapi.json`. This file feeds
|
||||
* `@hey-api/openapi-ts` (see `openapi-ts.config.ts`) which then regenerates
|
||||
* `src/generated/`.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @toolctrl/api-client-ts generate-spec
|
||||
* pnpm --filter @toolctrl/api-client-ts generate-spec -- --check
|
||||
*
|
||||
* `--check` re-runs the generation and exits non-zero if the on-disk
|
||||
* `openapi.json` would change — useful as a CI drift gate.
|
||||
*
|
||||
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { createOpenApiDocument } from "@toolctrl/api/openapi";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const outputPath = resolve(here, "..", "openapi.json");
|
||||
|
||||
function buildSpec(): string {
|
||||
const document = createOpenApiDocument({
|
||||
routing: {
|
||||
protocol: "https",
|
||||
apiHost: "api.toolctrl.de",
|
||||
} as never,
|
||||
});
|
||||
return `${JSON.stringify(document, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const checkMode = process.argv.includes("--check");
|
||||
const next = buildSpec();
|
||||
|
||||
if (checkMode) {
|
||||
if (!existsSync(outputPath)) {
|
||||
process.stderr.write(`openapi.json missing at ${outputPath} — run \`pnpm --filter @toolctrl/api-client-ts generate-spec\`.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const current = readFileSync(outputPath, "utf8");
|
||||
if (current !== next) {
|
||||
process.stderr.write(
|
||||
`openapi.json is stale — the upstream OpenAPI document has changed.\n` +
|
||||
`Run \`pnpm --filter @toolctrl/api-client-ts generate:all\` and commit the result.\n`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
process.stdout.write("openapi.json is in sync with the upstream document.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, next, "utf8");
|
||||
process.stdout.write(`Wrote ${outputPath} (${(next.length / 1024).toFixed(1)} KiB)\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createToolctrlClient, ToolctrlApiError, ToolctrlNetworkError } from "./client.js";
|
||||
|
||||
function mockFetchSequence(responses: Array<Response | Error>): typeof fetch {
|
||||
let index = 0;
|
||||
return (async () => {
|
||||
const next = responses[index++];
|
||||
if (next === undefined) {
|
||||
throw new Error("mock fetch exhausted");
|
||||
}
|
||||
if (next instanceof Error) throw next;
|
||||
return next;
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
describe("createToolctrlClient", () => {
|
||||
it("rejects when baseUrl is missing", () => {
|
||||
expect(() => createToolctrlClient({ baseUrl: "" } as never)).toThrow(/baseUrl/);
|
||||
});
|
||||
|
||||
it("strips trailing slashes from the base url", () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com///",
|
||||
fetch: mockFetchSequence([jsonResponse(200, { ok: true })]),
|
||||
});
|
||||
expect(client.baseUrl).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("sends Authorization bearer when bearerToken is set", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(200, { hello: "world" });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
auth: { bearerToken: "secret-token" },
|
||||
fetch: stub,
|
||||
});
|
||||
const result = await client.request<{ hello: string }>({ method: "GET", path: "/v1/me" });
|
||||
expect(result.hello).toBe("world");
|
||||
expect(captured.headers!.get("authorization")).toBe("Bearer secret-token");
|
||||
});
|
||||
|
||||
it("sends X-API-Key when apiKey is set", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(200, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
auth: { apiKey: "ak_test_123" },
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "GET", path: "/v1/ping" });
|
||||
expect(captured.headers!.get("x-api-key")).toBe("ak_test_123");
|
||||
});
|
||||
|
||||
it("auto-generates Idempotency-Key on POST", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(201, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "POST", path: "/v1/items", body: { foo: 1 } });
|
||||
expect(captured.headers!.get("idempotency-key")).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
it("does not auto-generate Idempotency-Key on GET", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(200, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "GET", path: "/v1/items" });
|
||||
expect(captured.headers!.get("idempotency-key")).toBeNull();
|
||||
});
|
||||
|
||||
it("respects autoGenerate=false", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(201, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
idempotency: { autoGenerate: false },
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "POST", path: "/v1/items", body: { foo: 1 } });
|
||||
expect(captured.headers!.get("idempotency-key")).toBeNull();
|
||||
});
|
||||
|
||||
it("honors caller-provided Idempotency-Key", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(201, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "POST", path: "/v1/items", body: {}, idempotencyKey: "caller-key" });
|
||||
expect(captured.headers!.get("idempotency-key")).toBe("caller-key");
|
||||
});
|
||||
|
||||
it("forwards X-Tenant-ID and Sentry trace headers", async () => {
|
||||
const captured: { headers: Headers | null } = { headers: null };
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.headers = init.headers as Headers;
|
||||
return jsonResponse(200, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
tenantId: "ten_abc",
|
||||
sentry: { trace: "abc-1", baggage: "k=v" },
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "GET", path: "/v1/ping" });
|
||||
expect(captured.headers!.get("x-tenant-id")).toBe("ten_abc");
|
||||
expect(captured.headers!.get("sentry-trace")).toBe("abc-1");
|
||||
expect(captured.headers!.get("baggage")).toBe("k=v");
|
||||
});
|
||||
|
||||
it("retries on 5xx then succeeds", async () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
retry: { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 5 },
|
||||
fetch: mockFetchSequence([jsonResponse(503, { error: "down" }), jsonResponse(200, { ok: true })]),
|
||||
});
|
||||
const result = await client.request<{ ok: boolean }>({ method: "GET", path: "/v1/ping" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("retries on 429 honoring Retry-After", async () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
retry: { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 10_000 },
|
||||
fetch: mockFetchSequence([
|
||||
jsonResponse(429, { error: "rate" }, { "retry-after": "0" }),
|
||||
jsonResponse(200, { ok: true }),
|
||||
]),
|
||||
});
|
||||
const t0 = Date.now();
|
||||
const result = await client.request<{ ok: boolean }>({ method: "GET", path: "/v1/ping" });
|
||||
expect(result.ok).toBe(true);
|
||||
// retry-after=0 should not have produced exponential backoff (which would be >=1000ms)
|
||||
expect(Date.now() - t0).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("throws ToolctrlApiError on non-retryable 4xx", async () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
retry: { maxAttempts: 3, baseDelayMs: 1 },
|
||||
fetch: mockFetchSequence([
|
||||
jsonResponse(404, { error: { code: "not_found", message: "missing", requestId: "req_1" } }, { "x-request-id": "req_1" }),
|
||||
]),
|
||||
});
|
||||
await expect(client.request({ method: "GET", path: "/v1/missing" })).rejects.toMatchObject({
|
||||
name: "ToolctrlApiError",
|
||||
status: 404,
|
||||
requestId: "req_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws ToolctrlNetworkError after exhausting retries", async () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
retry: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 },
|
||||
fetch: mockFetchSequence([new Error("ECONNRESET"), new Error("ECONNRESET")]),
|
||||
});
|
||||
await expect(client.request({ method: "GET", path: "/v1/ping" })).rejects.toBeInstanceOf(ToolctrlNetworkError);
|
||||
});
|
||||
|
||||
it("paginate auto-follows nextCursor and stops on null", async () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
fetch: mockFetchSequence([
|
||||
jsonResponse(200, { data: [{ id: 1 }, { id: 2 }], nextCursor: "c1" }),
|
||||
jsonResponse(200, { data: [{ id: 3 }], nextCursor: null }),
|
||||
]),
|
||||
});
|
||||
const items: Array<{ id: number }> = [];
|
||||
for await (const item of client.paginate<{ id: number }>("/v1/items")) {
|
||||
items.push(item);
|
||||
}
|
||||
expect(items.map((i) => i.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("paginate handles nextPage offset pagination", async () => {
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
fetch: mockFetchSequence([
|
||||
jsonResponse(200, { data: [{ id: "a" }], nextPage: 2 }),
|
||||
jsonResponse(200, { data: [{ id: "b" }] }),
|
||||
]),
|
||||
});
|
||||
const items: Array<{ id: string }> = [];
|
||||
for await (const item of client.paginate<{ id: string }>("/v1/items", { maxPages: 5 })) {
|
||||
items.push(item);
|
||||
}
|
||||
expect(items.map((i) => i.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("getter mode resolves bearer token freshly", async () => {
|
||||
let calls = 0;
|
||||
const captured: Array<string | null> = [];
|
||||
const stub: typeof fetch = (async (_url: unknown, init: RequestInit) => {
|
||||
captured.push((init.headers as Headers).get("authorization"));
|
||||
return jsonResponse(200, {});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = createToolctrlClient({
|
||||
baseUrl: "https://api.example.com",
|
||||
auth: {
|
||||
getToken: async () => `jwt-${++calls}`,
|
||||
},
|
||||
fetch: stub,
|
||||
});
|
||||
await client.request({ method: "GET", path: "/v1/a" });
|
||||
await client.request({ method: "GET", path: "/v1/b" });
|
||||
expect(captured).toEqual(["Bearer jwt-1", "Bearer jwt-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error types", () => {
|
||||
it("ToolctrlApiError carries status, body, and requestId", () => {
|
||||
const err = new ToolctrlApiError(403, "Forbidden", { error: { code: "forbidden" } }, "req_x");
|
||||
expect(err.name).toBe("ToolctrlApiError");
|
||||
expect(err.status).toBe(403);
|
||||
expect(err.requestId).toBe("req_x");
|
||||
});
|
||||
it("ToolctrlNetworkError preserves cause", () => {
|
||||
const cause = new Error("ECONNRESET");
|
||||
const err = new ToolctrlNetworkError("net", cause);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Higher-Level-Wrapper around the generated client.
|
||||
*
|
||||
* Adds:
|
||||
* - Bearer-token / API-key auth header injection
|
||||
* - Retry with exponential backoff (honoring Retry-After) on 429 + 5xx + network errors
|
||||
* - Auto-generated Idempotency-Key headers on POST/PUT/PATCH
|
||||
* - Async iterator pagination helper that auto-follows next-page cursors
|
||||
* - Optional Sentry trace propagation via sentry-trace + baggage headers
|
||||
*
|
||||
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface ToolctrlAuthConfig {
|
||||
/** Bearer-token style — set as `Authorization: Bearer <key>`. */
|
||||
bearerToken?: string;
|
||||
/** API-key style — set as `X-API-Key: <key>`. */
|
||||
apiKey?: string;
|
||||
/** Async getter that produces a fresh bearer token (e.g. for JWT refresh). */
|
||||
getToken?: () => Promise<string>;
|
||||
}
|
||||
|
||||
export interface ToolctrlRetryConfig {
|
||||
/** Maximum number of retry attempts (default 5). The initial request counts as 1. */
|
||||
maxAttempts?: number;
|
||||
/** Initial delay in ms before the first retry (default 1000). */
|
||||
baseDelayMs?: number;
|
||||
/** Upper bound for any individual retry delay (default 30000). */
|
||||
maxDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface ToolctrlIdempotencyConfig {
|
||||
/** Auto-generate `Idempotency-Key` headers on POST/PUT/PATCH. Defaults to true. */
|
||||
autoGenerate?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolctrlClientConfig {
|
||||
/** Base URL of the toolctrl API, e.g. `https://api.toolctrl.de`. */
|
||||
baseUrl: string;
|
||||
/** Auth configuration. Exactly one of bearerToken / apiKey / getToken should be set. */
|
||||
auth?: ToolctrlAuthConfig;
|
||||
/** Optional tenant scope; forwarded as `X-Tenant-ID` on every call. */
|
||||
tenantId?: string;
|
||||
/** User-Agent override. Defaults to `@toolctrl/api-client-ts/<version>`. */
|
||||
userAgent?: string;
|
||||
/** Retry policy for 429 + 5xx + network errors. */
|
||||
retry?: ToolctrlRetryConfig;
|
||||
/** Idempotency-Key policy. */
|
||||
idempotency?: ToolctrlIdempotencyConfig;
|
||||
/** Optional fetch implementation (defaults to global fetch). Useful for Capacitor or test stubs. */
|
||||
fetch?: typeof fetch;
|
||||
/** Sentry trace headers to forward (optional). */
|
||||
sentry?: { trace?: string; baggage?: string };
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
/** Optional next-page cursor. */
|
||||
nextCursor?: string | null;
|
||||
/** Optional next-page token (alternate API style). */
|
||||
nextPageToken?: string | null;
|
||||
/** Optional next-page number (offset pagination). */
|
||||
nextPage?: number | null;
|
||||
}
|
||||
|
||||
export interface PaginateOptions {
|
||||
/** Maximum number of pages to fetch (safety stop, defaults to 1000). */
|
||||
maxPages?: number;
|
||||
/** Initial query parameters. */
|
||||
query?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
export class ToolctrlApiError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly body: unknown;
|
||||
public readonly requestId?: string;
|
||||
constructor(status: number, message: string, body: unknown, requestId?: string) {
|
||||
super(message);
|
||||
this.name = "ToolctrlApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolctrlNetworkError extends Error {
|
||||
public readonly cause: unknown;
|
||||
constructor(message: string, cause: unknown) {
|
||||
super(message);
|
||||
this.name = "ToolctrlNetworkError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolctrlClient {
|
||||
/** Issue a typed request against the API. */
|
||||
request<T = unknown>(input: {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<T>;
|
||||
/** Async iterator that auto-follows cursor / token / page pagination. */
|
||||
paginate<T = unknown>(path: string, options?: PaginateOptions): AsyncIterable<T>;
|
||||
/** Re-fetch the bearer token via the configured getter, if any. */
|
||||
refreshAuth(): Promise<void>;
|
||||
/** The fully-resolved base URL (trailing slash stripped). */
|
||||
readonly baseUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_MAX_ATTEMPTS = 5;
|
||||
const DEFAULT_RETRY_BASE_MS = 1000;
|
||||
const DEFAULT_RETRY_MAX_MS = 30_000;
|
||||
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
||||
const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
const PACKAGE_VERSION = "0.1.0";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function computeBackoff(attempt: number, baseMs: number, maxMs: number): number {
|
||||
// attempt is 1-based: 1 -> base, 2 -> 2*base, 3 -> 4*base, ...
|
||||
const exp = Math.min(maxMs, baseMs * Math.pow(2, attempt - 1));
|
||||
// 0..exp jitter
|
||||
const jitter = Math.floor(Math.random() * Math.min(250, exp));
|
||||
return Math.min(maxMs, exp + jitter);
|
||||
}
|
||||
|
||||
function parseRetryAfter(header: string | null): number | null {
|
||||
if (!header) return null;
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.floor(seconds * 1000);
|
||||
}
|
||||
const dateMs = Date.parse(header);
|
||||
if (!Number.isNaN(dateMs)) {
|
||||
const delta = dateMs - Date.now();
|
||||
return delta > 0 ? delta : 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.length === 0 ? null : text;
|
||||
}
|
||||
|
||||
export function createToolctrlClient(config: ToolctrlClientConfig): ToolctrlClient {
|
||||
if (!config.baseUrl) {
|
||||
throw new Error("createToolctrlClient: baseUrl is required");
|
||||
}
|
||||
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
const fetchImpl: typeof fetch =
|
||||
config.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (undefined as unknown as typeof fetch));
|
||||
if (!fetchImpl) {
|
||||
throw new Error("createToolctrlClient: no fetch implementation available — pass `fetch` in the config");
|
||||
}
|
||||
const retryMaxAttempts = config.retry?.maxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS;
|
||||
const retryBaseMs = config.retry?.baseDelayMs ?? DEFAULT_RETRY_BASE_MS;
|
||||
const retryMaxMs = config.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_MS;
|
||||
const autoIdempotency = config.idempotency?.autoGenerate ?? true;
|
||||
let cachedToken: string | undefined = config.auth?.bearerToken;
|
||||
|
||||
async function resolveAuthHeader(): Promise<Record<string, string>> {
|
||||
if (config.auth?.apiKey) {
|
||||
return { "X-API-Key": config.auth.apiKey };
|
||||
}
|
||||
if (config.auth?.getToken) {
|
||||
const token = await config.auth.getToken();
|
||||
cachedToken = token;
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
if (cachedToken) {
|
||||
return { Authorization: `Bearer ${cachedToken}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildHeaders(method: string, extra?: Record<string, string>, idempotencyKey?: string): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set("Accept", "application/json");
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("User-Agent", config.userAgent ?? `@toolctrl/api-client-ts/${PACKAGE_VERSION}`);
|
||||
if (config.tenantId) headers.set("X-Tenant-ID", config.tenantId);
|
||||
if (config.sentry?.trace) headers.set("sentry-trace", config.sentry.trace);
|
||||
if (config.sentry?.baggage) headers.set("baggage", config.sentry.baggage);
|
||||
if (autoIdempotency && MUTATING_METHODS.has(method)) {
|
||||
headers.set("Idempotency-Key", idempotencyKey ?? randomUUID());
|
||||
} else if (idempotencyKey) {
|
||||
headers.set("Idempotency-Key", idempotencyKey);
|
||||
}
|
||||
if (extra) {
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: Record<string, string | number | boolean | undefined>): string {
|
||||
const url = new URL(path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`);
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function executeOnce(
|
||||
method: string,
|
||||
url: string,
|
||||
headers: Headers,
|
||||
body: string | undefined,
|
||||
): Promise<{ response: Response | null; error: unknown }> {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
return { response, error: null };
|
||||
} catch (err) {
|
||||
return { response: null, error: err };
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T = unknown>(input: {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<T> {
|
||||
const method = input.method.toUpperCase();
|
||||
const url = buildUrl(input.path, input.query);
|
||||
const authHeaders = await resolveAuthHeader();
|
||||
const headers = buildHeaders(method, { ...authHeaders, ...input.headers }, input.idempotencyKey);
|
||||
const body = input.body === undefined ? undefined : JSON.stringify(input.body);
|
||||
|
||||
let attempt = 1;
|
||||
let lastError: unknown = null;
|
||||
while (attempt <= retryMaxAttempts) {
|
||||
const { response, error } = await executeOnce(method, url, headers, body);
|
||||
|
||||
if (response) {
|
||||
if (response.ok) {
|
||||
return (await readBody(response)) as T;
|
||||
}
|
||||
|
||||
// Retry conditions
|
||||
if (RETRY_STATUS.has(response.status) && attempt < retryMaxAttempts) {
|
||||
const retryAfter = parseRetryAfter(response.headers.get("Retry-After"));
|
||||
const delay = retryAfter !== null ? Math.min(retryMaxMs, retryAfter) : computeBackoff(attempt, retryBaseMs, retryMaxMs);
|
||||
await sleep(delay);
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const errorBody = await readBody(response);
|
||||
const requestId = response.headers.get("x-request-id") ?? undefined;
|
||||
const message =
|
||||
errorBody && typeof errorBody === "object" && "error" in errorBody && errorBody.error && typeof errorBody.error === "object"
|
||||
? String((errorBody.error as { message?: unknown }).message ?? `HTTP ${response.status}`)
|
||||
: `HTTP ${response.status}`;
|
||||
throw new ToolctrlApiError(response.status, message, errorBody, requestId);
|
||||
}
|
||||
|
||||
// Network error path
|
||||
lastError = error;
|
||||
if (attempt < retryMaxAttempts) {
|
||||
await sleep(computeBackoff(attempt, retryBaseMs, retryMaxMs));
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
throw new ToolctrlNetworkError("Network request failed after retries", lastError);
|
||||
}
|
||||
|
||||
async function* paginate<T = unknown>(path: string, options: PaginateOptions = {}): AsyncIterable<T> {
|
||||
const maxPages = options.maxPages ?? 1000;
|
||||
let cursor: string | null | undefined = undefined;
|
||||
let pageToken: string | null | undefined = undefined;
|
||||
let pageNumber: number | null | undefined = undefined;
|
||||
let pages = 0;
|
||||
|
||||
while (pages < maxPages) {
|
||||
const query: Record<string, string | number | undefined> = { ...(options.query ?? {}) };
|
||||
if (cursor !== undefined && cursor !== null) query.cursor = cursor;
|
||||
if (pageToken !== undefined && pageToken !== null) query.pageToken = pageToken;
|
||||
if (pageNumber !== undefined && pageNumber !== null) query.page = pageNumber;
|
||||
|
||||
const result = (await request<PaginatedResponse<T>>({ method: "GET", path, query })) ?? {
|
||||
data: [] as T[],
|
||||
};
|
||||
const items = Array.isArray(result?.data) ? result.data : [];
|
||||
for (const item of items) yield item;
|
||||
|
||||
pages += 1;
|
||||
|
||||
const nextCursor = result?.nextCursor ?? null;
|
||||
const nextToken = result?.nextPageToken ?? null;
|
||||
const nextPage = result?.nextPage ?? null;
|
||||
|
||||
if (nextCursor) {
|
||||
cursor = nextCursor;
|
||||
pageToken = null;
|
||||
pageNumber = null;
|
||||
} else if (nextToken) {
|
||||
pageToken = nextToken;
|
||||
cursor = null;
|
||||
pageNumber = null;
|
||||
} else if (nextPage) {
|
||||
pageNumber = nextPage;
|
||||
cursor = null;
|
||||
pageToken = null;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (items.length === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuth(): Promise<void> {
|
||||
if (config.auth?.getToken) {
|
||||
cachedToken = await config.auth.getToken();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
paginate,
|
||||
refreshAuth,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAuthToken } from "./generated/core/auth.gen.js";
|
||||
import { createClient } from "./generated/client/client.gen.js";
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
createQuerySerializer,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from "./generated/client/utils.gen.js";
|
||||
|
||||
describe("generated auth helpers", () => {
|
||||
it("formats auth tokens by security scheme", async () => {
|
||||
await expect(getAuthToken({ scheme: "bearer", type: "http" }, "jwt")).resolves.toBe("Bearer jwt");
|
||||
await expect(getAuthToken({ scheme: "basic", type: "http" }, "user:pass")).resolves.toBe(`Basic ${btoa("user:pass")}`);
|
||||
await expect(getAuthToken({ in: "query", name: "api_key", type: "apiKey" }, () => "key")).resolves.toBe("key");
|
||||
await expect(getAuthToken({ type: "apiKey" }, undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated client utilities", () => {
|
||||
it("serializes query parameters with global and per-parameter overrides", () => {
|
||||
const querySerializer = createQuerySerializer({
|
||||
allowReserved: true,
|
||||
array: { explode: false, style: "pipeDelimited" },
|
||||
object: { explode: false, style: "form" },
|
||||
parameters: {
|
||||
ids: { allowReserved: false, array: { explode: true, style: "form" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
querySerializer({
|
||||
filter: { owner: "ops/team", status: "open" },
|
||||
ids: ["a/b", "c"],
|
||||
q: "a/b",
|
||||
skipped: undefined,
|
||||
})
|
||||
).toBe("filter=owner,ops/team,status,open&ids=a%2Fb&ids=c&q=a/b");
|
||||
});
|
||||
|
||||
it("infers response parsing from content type", () => {
|
||||
expect(getParseAs(null)).toBe("stream");
|
||||
expect(getParseAs("application/problem+json; charset=utf-8")).toBe("json");
|
||||
expect(getParseAs("multipart/form-data; boundary=x")).toBe("formData");
|
||||
expect(getParseAs("image/png")).toBe("blob");
|
||||
expect(getParseAs("text/csv")).toBe("text");
|
||||
expect(getParseAs("")).toBe("stream");
|
||||
expect(getParseAs("x-custom/value")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets auth in headers, query params, and cookies without overwriting existing values", async () => {
|
||||
const headers = new Headers({ Authorization: "Bearer existing", Cookie: "session=abc" });
|
||||
const query = { api_key: "existing" };
|
||||
|
||||
await setAuthParams({
|
||||
auth: ({ name }) => `token-for-${name ?? "default"}`,
|
||||
headers,
|
||||
query,
|
||||
security: [
|
||||
{ in: "header", name: "Authorization", scheme: "bearer", type: "http" },
|
||||
{ in: "query", name: "api_key", type: "apiKey" },
|
||||
{ in: "cookie", name: "csrf", type: "apiKey" },
|
||||
{ in: "header", name: "X-New-Token", type: "apiKey" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(headers.get("Authorization")).toBe("Bearer existing");
|
||||
expect(query.api_key).toBe("existing");
|
||||
expect(headers.get("Cookie")).toBe("session=abc; csrf=token-for-csrf");
|
||||
expect(headers.get("X-New-Token")).toBe("token-for-X-New-Token");
|
||||
});
|
||||
|
||||
it("merges config and headers while honoring removals", () => {
|
||||
const mergedHeaders = mergeHeaders(
|
||||
{ Accept: "application/json", "X-List": ["a", "b"], "X-Json": { ok: true } },
|
||||
new Headers({ Accept: "text/plain" }),
|
||||
{ "X-Json": null, "X-Skip": undefined }
|
||||
);
|
||||
|
||||
expect(mergedHeaders.get("Accept")).toBe("text/plain");
|
||||
expect(mergedHeaders.get("X-List")).toBe("a, b");
|
||||
expect(mergedHeaders.has("X-Json")).toBe(false);
|
||||
|
||||
const config = mergeConfigs({ baseUrl: "https://api.example.test/", headers: { A: "1" } }, { headers: { B: "2" } });
|
||||
const configHeaders = config.headers as Headers;
|
||||
expect(config.baseUrl).toBe("https://api.example.test");
|
||||
expect(configHeaders.get("A")).toBe("1");
|
||||
expect(configHeaders.get("B")).toBe("2");
|
||||
});
|
||||
|
||||
it("creates default config, build URLs, and manages interceptors", () => {
|
||||
const config = createConfig({ baseUrl: "https://api.example.test" });
|
||||
|
||||
expect(config.parseAs).toBe("auto");
|
||||
expect(config.headers).toEqual({ "Content-Type": "application/json" });
|
||||
expect(
|
||||
buildUrl({
|
||||
...config,
|
||||
path: { id: "tool/a" },
|
||||
query: { q: "drill" },
|
||||
url: "/v1/tools/{id}",
|
||||
})
|
||||
).toBe("https://api.example.test/v1/tools/tool%2Fa?q=drill");
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, { tag: string }>();
|
||||
const fn = (request: Request) => request;
|
||||
const id = interceptors.request.use(fn);
|
||||
|
||||
expect(interceptors.request.exists(id)).toBe(true);
|
||||
expect(interceptors.request.update(id, fn)).toBe(id);
|
||||
interceptors.request.eject(fn);
|
||||
expect(interceptors.request.exists(id)).toBe(false);
|
||||
interceptors.request.clear();
|
||||
expect(interceptors.request.fns).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated fetch client", () => {
|
||||
it("builds requests, runs interceptors, validates, transforms, and returns data", async () => {
|
||||
const seen: Array<string> = [];
|
||||
const client = createClient({
|
||||
baseUrl: "https://api.example.test",
|
||||
fetch: async (requestInfo) => {
|
||||
const request = requestInfo as Request;
|
||||
seen.push(request.headers.get("X-Request-Interceptor") ?? "");
|
||||
expect(request.method).toBe("POST");
|
||||
expect(request.url).toBe("https://api.example.test/v1/tools?q=drill");
|
||||
expect(await request.text()).toBe('{"name":"press"}');
|
||||
return new Response('{"id":"tool_1"}', { headers: { "Content-Type": "application/json" } });
|
||||
},
|
||||
responseStyle: "data",
|
||||
});
|
||||
|
||||
client.interceptors.request.use((request) => {
|
||||
request.headers.set("X-Request-Interceptor", "ran");
|
||||
return request;
|
||||
});
|
||||
client.interceptors.response.use((response) => {
|
||||
response.headers.set("X-Response-Interceptor", "ran");
|
||||
return response;
|
||||
});
|
||||
|
||||
const data = await client.post({
|
||||
body: { name: "press" },
|
||||
query: { q: "drill" },
|
||||
responseTransformer: async (body) => ({ ...(body as Record<string, unknown>), transformed: true }),
|
||||
responseValidator: async (body) => {
|
||||
expect(body).toEqual({ id: "tool_1" });
|
||||
},
|
||||
url: "/v1/tools",
|
||||
});
|
||||
|
||||
expect(seen).toEqual(["ran"]);
|
||||
expect(data).toEqual({ id: "tool_1", transformed: true });
|
||||
});
|
||||
|
||||
it("returns structured errors when throwOnError is disabled", async () => {
|
||||
const client = createClient({
|
||||
baseUrl: "https://api.example.test",
|
||||
fetch: async () => new Response('{"error":"nope"}', { status: 400 }),
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
client.interceptors.error.use((error) => ({ wrapped: error }));
|
||||
|
||||
await expect(client.get({ url: "/v1/fail" })).resolves.toMatchObject({
|
||||
error: { wrapped: { error: "nope" } },
|
||||
response: { status: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns typed empty responses for no-content results", async () => {
|
||||
const client = createClient({
|
||||
baseUrl: "https://api.example.test",
|
||||
fetch: async () => new Response(null, { status: 204 }),
|
||||
responseStyle: "data",
|
||||
});
|
||||
|
||||
await expect(client.delete({ parseAs: "json", url: "/v1/tools/tool_1" })).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "./generated/core/bodySerializer.gen.js";
|
||||
import { buildClientParams } from "./generated/core/params.gen.js";
|
||||
import {
|
||||
defaultPathSerializer,
|
||||
getUrl,
|
||||
getValidRequestBody,
|
||||
} from "./generated/core/utils.gen.js";
|
||||
import {
|
||||
separatorArrayExplode,
|
||||
separatorArrayNoExplode,
|
||||
separatorObjectExplode,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from "./generated/core/pathSerializer.gen.js";
|
||||
import { serializeQueryKeyValue, stringifyToJsonValue } from "./generated/core/queryKeySerializer.gen.js";
|
||||
|
||||
describe("generated core body serializers", () => {
|
||||
it("serializes JSON bodies with bigint values", () => {
|
||||
expect(jsonBodySerializer.bodySerializer({ id: 12n, name: "tool" })).toBe('{"id":"12","name":"tool"}');
|
||||
});
|
||||
|
||||
it("serializes FormData bodies while skipping nullish values", async () => {
|
||||
const file = new Blob(["hello"], { type: "text/plain" });
|
||||
const date = new Date("2026-05-25T10:30:00.000Z");
|
||||
|
||||
const data = formDataBodySerializer.bodySerializer({
|
||||
ignored: undefined,
|
||||
list: ["a", 2],
|
||||
meta: { ok: true },
|
||||
stamp: date,
|
||||
upload: file,
|
||||
});
|
||||
|
||||
expect(data.getAll("list")).toEqual(["a", "2"]);
|
||||
expect(data.get("meta")).toBe('{"ok":true}');
|
||||
expect(data.get("stamp")).toBe(date.toISOString());
|
||||
expect(data.get("upload")).toBeInstanceOf(Blob);
|
||||
expect(data.has("ignored")).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes URLSearchParams bodies with repeated array entries", () => {
|
||||
expect(
|
||||
urlSearchParamsBodySerializer.bodySerializer({
|
||||
filters: ["active", { owner: "me" }],
|
||||
page: 2,
|
||||
skipped: null,
|
||||
})
|
||||
).toBe("filters=active&filters=%7B%22owner%22%3A%22me%22%7D&page=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated core parameter mapping", () => {
|
||||
it("maps positional and object parameters into request slots", () => {
|
||||
const params = buildClientParams(
|
||||
[
|
||||
"project-1",
|
||||
{ raw: true },
|
||||
{
|
||||
$headers_traceparent: "00-ab",
|
||||
$query_search: "drill",
|
||||
apiVersion: "2026-05",
|
||||
extra: "kept",
|
||||
payload: { name: "new tool" },
|
||||
},
|
||||
],
|
||||
[
|
||||
{ in: "path", key: "projectId", map: "project_id" },
|
||||
{ in: "body" },
|
||||
{
|
||||
allowExtra: { query: true },
|
||||
args: [
|
||||
{ in: "headers", key: "apiVersion", map: "X-Api-Version" },
|
||||
{ key: "payload", map: "body" },
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(params).toEqual({
|
||||
body: { name: "new tool" },
|
||||
headers: {
|
||||
"X-Api-Version": "2026-05",
|
||||
traceparent: "00-ab",
|
||||
},
|
||||
path: { project_id: "project-1" },
|
||||
query: {
|
||||
extra: "kept",
|
||||
search: "drill",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes empty slots from the mapped params", () => {
|
||||
expect(buildClientParams(["abc"], [{ in: "path", key: "id" }])).toEqual({ path: { id: "abc" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated core path and URL serializers", () => {
|
||||
it("serializes primitive, array, and object path params", () => {
|
||||
expect(
|
||||
defaultPathSerializer({
|
||||
path: {
|
||||
coords: ["10", "20"],
|
||||
filter: { status: "open", owner: "ops" },
|
||||
id: "tool/a",
|
||||
tag: "beta",
|
||||
},
|
||||
url: "/v1/tools/{id}/{.tag}/{;coords*}/{filter}",
|
||||
})
|
||||
).toBe("/v1/tools/tool%2Fa/.beta/;coords=10;coords=20/status,open,owner,ops");
|
||||
});
|
||||
|
||||
it("serializes query and path parameters into a full URL", () => {
|
||||
const url = getUrl({
|
||||
baseUrl: "https://api.example.test",
|
||||
path: { id: "a/b" },
|
||||
query: { active: true, tags: ["red", "blue"] },
|
||||
querySerializer: (query) => `?active=${query.active}&tags=${(query.tags as string[]).join(",")}`,
|
||||
url: "v1/tools/{id}",
|
||||
});
|
||||
|
||||
expect(url).toBe("https://api.example.test/v1/tools/a%2Fb?active=true&tags=red,blue");
|
||||
});
|
||||
|
||||
it("exposes OpenAPI separator and primitive serialization behavior", () => {
|
||||
expect(separatorArrayExplode("label")).toBe(".");
|
||||
expect(separatorArrayNoExplode("pipeDelimited")).toBe("|");
|
||||
expect(separatorObjectExplode("matrix")).toBe(";");
|
||||
expect(serializeArrayParam({ explode: false, name: "ids", style: "pipeDelimited", value: ["a/b", "c"] })).toBe("ids=a%2Fb|c");
|
||||
expect(serializePrimitiveParam({ allowReserved: true, name: "q", value: "a/b?c" })).toBe("q=a/b?c");
|
||||
expect(() => serializePrimitiveParam({ name: "bad", value: { nested: true } as never })).toThrow(/Deeply-nested/);
|
||||
});
|
||||
|
||||
it("serializes object params for dates, labels, and deep objects", () => {
|
||||
const date = new Date("2026-05-25T12:00:00.000Z");
|
||||
|
||||
expect(serializeObjectParam({ explode: true, name: "at", style: "form", value: date, valueOnly: true })).toBe(date.toISOString());
|
||||
expect(serializeObjectParam({ explode: false, name: "filter", style: "label", value: { status: "open" } })).toBe(".status,open");
|
||||
expect(serializeObjectParam({ explode: true, name: "filter", style: "deepObject", value: { status: "open" } })).toBe(
|
||||
"filter[status]=open"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the valid request body shape for serialized, plain, and empty bodies", () => {
|
||||
expect(getValidRequestBody({ body: { ok: true } })).toEqual({ ok: true });
|
||||
expect(getValidRequestBody({ body: "", bodySerializer: JSON.stringify })).toBeNull();
|
||||
expect(getValidRequestBody({ body: { ok: true }, bodySerializer: JSON.stringify, serializedBody: '{"ok":true}' })).toBe('{"ok":true}');
|
||||
expect(getValidRequestBody({ body: { ok: true }, bodySerializer: JSON.stringify, serializedBody: "" })).toBeNull();
|
||||
expect(getValidRequestBody({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated core query key serialization", () => {
|
||||
it("normalizes query key values into JSON-safe shapes", () => {
|
||||
const params = new URLSearchParams([
|
||||
["b", "2"],
|
||||
["a", "1"],
|
||||
["a", "3"],
|
||||
]);
|
||||
|
||||
expect(serializeQueryKeyValue(params)).toEqual({ a: ["1", "3"], b: "2" });
|
||||
expect(serializeQueryKeyValue({ createdAt: new Date("2026-05-25T00:00:00.000Z"), id: 123n })).toEqual({
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
id: "123",
|
||||
});
|
||||
expect(serializeQueryKeyValue(Object.create(null))).toEqual({});
|
||||
expect(serializeQueryKeyValue(Symbol("skip"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when JSON serialization cannot produce a value", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
|
||||
expect(stringifyToJsonValue(circular)).toBeUndefined();
|
||||
expect(stringifyToJsonValue(() => undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSseClient } from "./generated/core/serverSentEvents.gen.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function streamChunks(chunks: Array<string>, options: { errorAfterChunks?: Error } = {}) {
|
||||
let index = 0;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(encoder.encode(chunks[index]));
|
||||
index += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.errorAfterChunks) {
|
||||
controller.error(options.errorAfterChunks);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requestCompatibleSignal() {
|
||||
return new Request("https://signal.example.test").signal;
|
||||
}
|
||||
|
||||
describe("generated SSE client", () => {
|
||||
it("parses events, transforms JSON payloads, and resumes retries with Last-Event-ID", async () => {
|
||||
const requests: Array<Request> = [];
|
||||
const events: Array<unknown> = [];
|
||||
const retryDelays: Array<number> = [];
|
||||
const errors: Array<unknown> = [];
|
||||
|
||||
const client = createSseClient<{ kind: string; count: number }>({
|
||||
fetch: async (requestInfo) => {
|
||||
const request = requestInfo as Request;
|
||||
requests.push(request);
|
||||
|
||||
if (requests.length === 1) {
|
||||
return new Response(
|
||||
streamChunks(
|
||||
[
|
||||
"id: cursor-1\r\nevent: snapshot\r\nretry: 25\r\ndata: {\"kind\":\"snapshot\",\"count\":1}\r\n\r\n",
|
||||
],
|
||||
{ errorAfterChunks: new Error("socket closed") }
|
||||
),
|
||||
{ headers: { "Content-Type": "text/event-stream" } }
|
||||
);
|
||||
}
|
||||
|
||||
expect(request.headers.get("Last-Event-ID")).toBe("cursor-1");
|
||||
|
||||
return new Response(streamChunks(["event: notification\ndata: plain text\n\n"]), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
},
|
||||
onSseError: (error) => errors.push(error),
|
||||
onSseEvent: (event) => events.push(event),
|
||||
responseTransformer: async (data) => ({ ...(data as Record<string, unknown>), transformed: true }),
|
||||
responseValidator: async (data) => {
|
||||
expect(data).toEqual({ kind: "snapshot", count: 1 });
|
||||
},
|
||||
signal: requestCompatibleSignal(),
|
||||
sseMaxRetryAttempts: 2,
|
||||
sseSleepFn: async (ms) => {
|
||||
retryDelays.push(ms);
|
||||
},
|
||||
url: "https://api.example.test/v1/notifications/stream",
|
||||
});
|
||||
|
||||
await expect(client.stream.next()).resolves.toEqual({
|
||||
done: false,
|
||||
value: { kind: "snapshot", count: 1, transformed: true },
|
||||
});
|
||||
await expect(client.stream.next()).resolves.toEqual({
|
||||
done: false,
|
||||
value: "plain text",
|
||||
});
|
||||
await expect(client.stream.next()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0].headers.has("Last-Event-ID")).toBe(false);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
data: { kind: "snapshot", count: 1, transformed: true },
|
||||
event: "snapshot",
|
||||
id: "cursor-1",
|
||||
retry: 25,
|
||||
},
|
||||
{
|
||||
data: "plain text",
|
||||
event: "notification",
|
||||
id: "cursor-1",
|
||||
retry: 25,
|
||||
},
|
||||
]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(retryDelays).toEqual([25]);
|
||||
});
|
||||
|
||||
it("reports failed responses and stops when retry attempts are exhausted", async () => {
|
||||
const errors: Array<unknown> = [];
|
||||
|
||||
const client = createSseClient({
|
||||
fetch: async () => new Response("nope", { status: 503, statusText: "Unavailable" }),
|
||||
onSseError: (error) => errors.push(error),
|
||||
signal: requestCompatibleSignal(),
|
||||
sseMaxRetryAttempts: 1,
|
||||
sseSleepFn: async () => {
|
||||
throw new Error("sleep should not run after final attempt");
|
||||
},
|
||||
url: "https://api.example.test/v1/notifications/stream",
|
||||
});
|
||||
|
||||
await expect(client.stream.next()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(Error);
|
||||
expect((errors[0] as Error).message).toBe("SSE failed: 503 Unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
|
||||
import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "https://api.toolctrl.de" }));
|
||||
@@ -0,0 +1,267 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from "../core/serverSentEvents.gen";
|
||||
import type { HttpMethod } from "../core/types.gen";
|
||||
import { getValidRequestBody } from "../core/utils.gen";
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
|
||||
import { buildUrl, createConfig, createInterceptors, getParseAs, mergeConfigs, mergeHeaders, setAuthParams } from "./utils.gen";
|
||||
|
||||
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
|
||||
const beforeRequest = async <
|
||||
TData = unknown,
|
||||
TResponseStyle extends "data" | "fields" = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>(
|
||||
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>
|
||||
) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined as string | undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === "") {
|
||||
opts.headers.delete("Content-Type");
|
||||
}
|
||||
|
||||
const resolvedOpts = opts as typeof opts & ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
|
||||
const url = buildUrl(resolvedOpts);
|
||||
|
||||
return { opts: resolvedOpts, url };
|
||||
};
|
||||
|
||||
const request: Client["request"] = async (options) => {
|
||||
const throwOnError = options.throwOnError ?? _config.throwOnError;
|
||||
const responseStyle = options.responseStyle ?? _config.responseStyle;
|
||||
|
||||
let request: Request | undefined;
|
||||
let response: Response | undefined;
|
||||
|
||||
try {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: "follow",
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
|
||||
response = await _fetch(request);
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs = (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
||||
|
||||
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
case "blob":
|
||||
case "text":
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case "formData":
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case "stream":
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case "json":
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === "data"
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
case "blob":
|
||||
case "formData":
|
||||
case "text":
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case "json": {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case "stream":
|
||||
return opts.responseStyle === "data"
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === "json") {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === "data"
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
throw jsonError ?? textError;
|
||||
} catch (error) {
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || {};
|
||||
|
||||
if (throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return responseStyle === "data"
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
const _buildUrl: Client["buildUrl"] = (options) => buildUrl({ ..._config, ...options });
|
||||
|
||||
return {
|
||||
buildUrl: _buildUrl,
|
||||
connect: makeMethodFn("CONNECT"),
|
||||
delete: makeMethodFn("DELETE"),
|
||||
get: makeMethodFn("GET"),
|
||||
getConfig,
|
||||
head: makeMethodFn("HEAD"),
|
||||
interceptors,
|
||||
options: makeMethodFn("OPTIONS"),
|
||||
patch: makeMethodFn("PATCH"),
|
||||
post: makeMethodFn("POST"),
|
||||
put: makeMethodFn("PUT"),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn("CONNECT"),
|
||||
delete: makeSseFn("DELETE"),
|
||||
get: makeSseFn("GET"),
|
||||
head: makeSseFn("HEAD"),
|
||||
options: makeSseFn("OPTIONS"),
|
||||
patch: makeSseFn("PATCH"),
|
||||
post: makeSseFn("POST"),
|
||||
put: makeSseFn("PUT"),
|
||||
trace: makeSseFn("TRACE"),
|
||||
},
|
||||
trace: makeMethodFn("TRACE"),
|
||||
} as Client;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from "../core/auth.gen";
|
||||
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.gen";
|
||||
export { buildClientParams } from "../core/params.gen";
|
||||
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
|
||||
export { createClient } from "./client.gen";
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
Config,
|
||||
CreateClientConfig,
|
||||
Options,
|
||||
RequestOptions,
|
||||
RequestResult,
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from "./types.gen";
|
||||
export { createConfig, mergeHeaders } from "./utils.gen";
|
||||
@@ -0,0 +1,184 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from "../core/auth.gen";
|
||||
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
|
||||
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen";
|
||||
import type { Middleware } from "./utils.gen";
|
||||
|
||||
export type ResponseStyle = "data" | "fields";
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions> extends Omit<RequestInit, "body" | "headers" | "method">, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T["baseUrl"];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T["throwOnError"];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<ServerSentEventsOptions<TData>, "onRequest" | "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
headers: Headers;
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends "data"
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends "data"
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
/** request may be undefined, because error may be from building the request object itself */
|
||||
request?: Request;
|
||||
/** response may be undefined, because error may be from building the request object itself or from a network error */
|
||||
response?: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = "fields">(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = "fields">(
|
||||
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, "method">
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = "fields">(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> & Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, "url">);
|
||||
@@ -0,0 +1,288 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from "../core/auth.gen";
|
||||
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||
import { jsonBodySerializer } from "../core/bodySerializer.gen";
|
||||
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
|
||||
import { getUrl } from "../core/utils.gen";
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === "object") {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "form",
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === "object") {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "deepObject",
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join("&");
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return "stream";
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(";")[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
if (cleanContent === "multipart/form-data") {
|
||||
return "formData";
|
||||
}
|
||||
|
||||
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
|
||||
return "blob";
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith("text/")) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, "auth" | "query"> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, "security"> &
|
||||
Pick<RequestOptions, "auth" | "query"> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? "Authorization";
|
||||
|
||||
switch (auth.in) {
|
||||
case "query":
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case "cookie":
|
||||
options.headers.append("Cookie", `${name}=${token}`);
|
||||
break;
|
||||
case "header":
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client["buildUrl"] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer: typeof options.querySerializer === "function" ? options.querySerializer : createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith("/")) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e., their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
/** response may be undefined due to a network error where no response object is produced */
|
||||
response: Res | undefined,
|
||||
/** request may be undefined, because error may be from building the request object itself */
|
||||
request: Req | undefined,
|
||||
options: Options
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === "number") {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: "form",
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: "deepObject",
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {}
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: "auto",
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: "header" | "query" | "cookie";
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: "basic" | "bearer";
|
||||
type: "apiKey" | "http";
|
||||
}
|
||||
|
||||
export const getAuthToken = async (auth: Auth, callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken): Promise<string | undefined> => {
|
||||
const token = typeof callback === "function" ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === "bearer") {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === "basic") {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen";
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: unknown) => unknown;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === "string" || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||
if (typeof value === "string") {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: (body: unknown): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: (body: unknown): string => JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: (body: unknown): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = "body" | "headers" | "path" | "query";
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, "body">;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, "body">;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: "body",
|
||||
$headers_: "headers",
|
||||
$path_: "path",
|
||||
$query_: "query",
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ("in" in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ("key" in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value) && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("in" in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ("allowExtra" in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = "label" | "matrix" | "simple";
|
||||
export type ObjectStyle = "form" | "deepObject";
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "form":
|
||||
return ",";
|
||||
case "pipeDelimited":
|
||||
return "|";
|
||||
case "spaceDelimited":
|
||||
return "%20";
|
||||
default:
|
||||
return ",";
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case "label":
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
return `;${name}=${joinedValues}`;
|
||||
case "simple":
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === "label" || style === "simple") {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== "deepObject" && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(",");
|
||||
switch (style) {
|
||||
case "form":
|
||||
return `${name}=${joinedValues}`;
|
||||
case "label":
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === "deepObject" ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
})
|
||||
)
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from "./types.gen";
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
|
||||
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit["body"];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
|
||||
};
|
||||
|
||||
export function createSseClient<TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
|
||||
const headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set("Last-Event-ID", lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: "follow",
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.body) throw new Error("No body in SSE response");
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
|
||||
let buffer = "";
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
buffer = buffer.replace(/\r\n?/g, "\n"); // normalize line endings
|
||||
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop() ?? "";
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split("\n");
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ""));
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.replace(/^event:\s*/, "");
|
||||
} else if (line.startsWith("id:")) {
|
||||
lastEventId = line.replace(/^id:\s*/, "");
|
||||
} else if (line.startsWith("retry:")) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join("\n");
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from "./auth.gen";
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
|
||||
|
||||
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
|
||||
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?: RequestInit["headers"] | Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g., converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never] ? true : [T] extends [never | undefined] ? ([undefined] extends [T] ? false : true) : false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
|
||||
import { type ArraySeparatorStyle, serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "./pathSerializer.gen";
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = "simple";
|
||||
|
||||
if (name.endsWith("*")) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith(".")) {
|
||||
name = name.substring(1);
|
||||
style = "label";
|
||||
} else if (name.startsWith(";")) {
|
||||
name = name.substring(1);
|
||||
style = "matrix";
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === "matrix") {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? "") + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : "";
|
||||
if (search.startsWith("?")) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: { body?: unknown; bodySerializer?: BodySerializer | null; serializedBody?: unknown }) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ("serializedBody" in options) {
|
||||
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e., client-axios)
|
||||
return options.body !== "" ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export {
|
||||
acceptWorkspaceInvite,
|
||||
createAnalyticsEvent,
|
||||
createAnalyticsPageView,
|
||||
createLegacyEntityRow,
|
||||
createLegacyRowRelationship,
|
||||
createSupportTicketFromInboundEmail,
|
||||
createWorkspaceInvite,
|
||||
deleteLegacyEntityRow,
|
||||
deleteLegacyRowRelationship,
|
||||
enqueueExportArtifactRetention,
|
||||
getAccount,
|
||||
getAdminAnalyticsOverview,
|
||||
getApiDocs,
|
||||
getApiUsage,
|
||||
getCurrentUserSession,
|
||||
getExportDownloadUrl,
|
||||
getExportJobProgress,
|
||||
getHealthz,
|
||||
getLegacyEntityByName,
|
||||
getLegacyEntityRow,
|
||||
getLegacyRowRelationship,
|
||||
getMetrics,
|
||||
getOpenApiDocument,
|
||||
getPublicAnalyticsOverview,
|
||||
getReadyz,
|
||||
getStatus,
|
||||
getV1Health,
|
||||
listAccounts,
|
||||
listLegacyEntityRows,
|
||||
listWorkspaceAuthProviders,
|
||||
listWorkspaceInvites,
|
||||
loginWithPassword,
|
||||
logoutApiSession,
|
||||
type Options,
|
||||
postDeviceRegister,
|
||||
postDeviceRevoke,
|
||||
receiveEmailSequenceMailjetWebhook,
|
||||
receiveStripeBillingWebhook,
|
||||
refreshJwtToken,
|
||||
retryEventWebhookAttempt,
|
||||
revalidateWorkspaceContext,
|
||||
revokeWorkspaceInvite,
|
||||
streamNotifications,
|
||||
updateLegacyEntityRow,
|
||||
uploadAiFile,
|
||||
validateApiKey,
|
||||
} from "./sdk.gen";
|
||||
export type {
|
||||
AcceptWorkspaceInviteData,
|
||||
AcceptWorkspaceInviteError,
|
||||
AcceptWorkspaceInviteErrors,
|
||||
AcceptWorkspaceInviteResponse,
|
||||
AcceptWorkspaceInviteResponses,
|
||||
ClientOptions,
|
||||
CreateAnalyticsEventData,
|
||||
CreateAnalyticsEventError,
|
||||
CreateAnalyticsEventErrors,
|
||||
CreateAnalyticsEventResponse,
|
||||
CreateAnalyticsEventResponses,
|
||||
CreateAnalyticsPageViewData,
|
||||
CreateAnalyticsPageViewError,
|
||||
CreateAnalyticsPageViewErrors,
|
||||
CreateAnalyticsPageViewResponse,
|
||||
CreateAnalyticsPageViewResponses,
|
||||
CreateLegacyEntityRowData,
|
||||
CreateLegacyEntityRowError,
|
||||
CreateLegacyEntityRowErrors,
|
||||
CreateLegacyEntityRowResponse,
|
||||
CreateLegacyEntityRowResponses,
|
||||
CreateLegacyRowRelationshipData,
|
||||
CreateLegacyRowRelationshipError,
|
||||
CreateLegacyRowRelationshipErrors,
|
||||
CreateLegacyRowRelationshipResponse,
|
||||
CreateLegacyRowRelationshipResponses,
|
||||
CreateSupportTicketFromInboundEmailData,
|
||||
CreateSupportTicketFromInboundEmailError,
|
||||
CreateSupportTicketFromInboundEmailErrors,
|
||||
CreateSupportTicketFromInboundEmailResponses,
|
||||
CreateWorkspaceInviteData,
|
||||
CreateWorkspaceInviteError,
|
||||
CreateWorkspaceInviteErrors,
|
||||
CreateWorkspaceInviteResponse,
|
||||
CreateWorkspaceInviteResponses,
|
||||
DeleteLegacyEntityRowData,
|
||||
DeleteLegacyEntityRowError,
|
||||
DeleteLegacyEntityRowErrors,
|
||||
DeleteLegacyEntityRowResponse,
|
||||
DeleteLegacyEntityRowResponses,
|
||||
DeleteLegacyRowRelationshipData,
|
||||
DeleteLegacyRowRelationshipError,
|
||||
DeleteLegacyRowRelationshipErrors,
|
||||
DeleteLegacyRowRelationshipResponse,
|
||||
DeleteLegacyRowRelationshipResponses,
|
||||
EnqueueExportArtifactRetentionData,
|
||||
EnqueueExportArtifactRetentionError,
|
||||
EnqueueExportArtifactRetentionErrors,
|
||||
EnqueueExportArtifactRetentionResponse,
|
||||
EnqueueExportArtifactRetentionResponses,
|
||||
GetAccountData,
|
||||
GetAccountError,
|
||||
GetAccountErrors,
|
||||
GetAccountResponse,
|
||||
GetAccountResponses,
|
||||
GetAdminAnalyticsOverviewData,
|
||||
GetAdminAnalyticsOverviewError,
|
||||
GetAdminAnalyticsOverviewErrors,
|
||||
GetAdminAnalyticsOverviewResponse,
|
||||
GetAdminAnalyticsOverviewResponses,
|
||||
GetApiDocsData,
|
||||
GetApiDocsResponses,
|
||||
GetApiUsageData,
|
||||
GetApiUsageError,
|
||||
GetApiUsageErrors,
|
||||
GetApiUsageResponse,
|
||||
GetApiUsageResponses,
|
||||
GetCurrentUserSessionData,
|
||||
GetCurrentUserSessionError,
|
||||
GetCurrentUserSessionErrors,
|
||||
GetCurrentUserSessionResponse,
|
||||
GetCurrentUserSessionResponses,
|
||||
GetExportDownloadUrlData,
|
||||
GetExportDownloadUrlError,
|
||||
GetExportDownloadUrlErrors,
|
||||
GetExportDownloadUrlResponse,
|
||||
GetExportDownloadUrlResponses,
|
||||
GetExportJobProgressData,
|
||||
GetExportJobProgressError,
|
||||
GetExportJobProgressErrors,
|
||||
GetExportJobProgressResponse,
|
||||
GetExportJobProgressResponses,
|
||||
GetHealthzData,
|
||||
GetHealthzResponse,
|
||||
GetHealthzResponses,
|
||||
GetLegacyEntityByNameData,
|
||||
GetLegacyEntityByNameError,
|
||||
GetLegacyEntityByNameErrors,
|
||||
GetLegacyEntityByNameResponses,
|
||||
GetLegacyEntityRowData,
|
||||
GetLegacyEntityRowError,
|
||||
GetLegacyEntityRowErrors,
|
||||
GetLegacyEntityRowResponse,
|
||||
GetLegacyEntityRowResponses,
|
||||
GetLegacyRowRelationshipData,
|
||||
GetLegacyRowRelationshipError,
|
||||
GetLegacyRowRelationshipErrors,
|
||||
GetLegacyRowRelationshipResponse,
|
||||
GetLegacyRowRelationshipResponses,
|
||||
GetMetricsData,
|
||||
GetMetricsError,
|
||||
GetMetricsErrors,
|
||||
GetMetricsResponse,
|
||||
GetMetricsResponses,
|
||||
GetOpenApiDocumentData,
|
||||
GetOpenApiDocumentResponses,
|
||||
GetPublicAnalyticsOverviewData,
|
||||
GetPublicAnalyticsOverviewError,
|
||||
GetPublicAnalyticsOverviewErrors,
|
||||
GetPublicAnalyticsOverviewResponse,
|
||||
GetPublicAnalyticsOverviewResponses,
|
||||
GetReadyzData,
|
||||
GetReadyzError,
|
||||
GetReadyzErrors,
|
||||
GetReadyzResponse,
|
||||
GetReadyzResponses,
|
||||
GetStatusData,
|
||||
GetStatusError,
|
||||
GetStatusErrors,
|
||||
GetStatusResponse,
|
||||
GetStatusResponses,
|
||||
GetV1HealthData,
|
||||
GetV1HealthResponse,
|
||||
GetV1HealthResponses,
|
||||
ListAccountsData,
|
||||
ListAccountsError,
|
||||
ListAccountsErrors,
|
||||
ListAccountsResponse,
|
||||
ListAccountsResponses,
|
||||
ListLegacyEntityRowsData,
|
||||
ListLegacyEntityRowsError,
|
||||
ListLegacyEntityRowsErrors,
|
||||
ListLegacyEntityRowsResponse,
|
||||
ListLegacyEntityRowsResponses,
|
||||
ListWorkspaceAuthProvidersData,
|
||||
ListWorkspaceAuthProvidersError,
|
||||
ListWorkspaceAuthProvidersErrors,
|
||||
ListWorkspaceAuthProvidersResponse,
|
||||
ListWorkspaceAuthProvidersResponses,
|
||||
ListWorkspaceInvitesData,
|
||||
ListWorkspaceInvitesError,
|
||||
ListWorkspaceInvitesErrors,
|
||||
ListWorkspaceInvitesResponse,
|
||||
ListWorkspaceInvitesResponses,
|
||||
LoginWithPasswordData,
|
||||
LoginWithPasswordError,
|
||||
LoginWithPasswordErrors,
|
||||
LoginWithPasswordResponses,
|
||||
LogoutApiSessionData,
|
||||
LogoutApiSessionError,
|
||||
LogoutApiSessionErrors,
|
||||
LogoutApiSessionResponses,
|
||||
PostDeviceRegisterData,
|
||||
PostDeviceRegisterError,
|
||||
PostDeviceRegisterErrors,
|
||||
PostDeviceRegisterResponse,
|
||||
PostDeviceRegisterResponses,
|
||||
PostDeviceRevokeData,
|
||||
PostDeviceRevokeError,
|
||||
PostDeviceRevokeErrors,
|
||||
PostDeviceRevokeResponse,
|
||||
PostDeviceRevokeResponses,
|
||||
ReceiveEmailSequenceMailjetWebhookData,
|
||||
ReceiveEmailSequenceMailjetWebhookError,
|
||||
ReceiveEmailSequenceMailjetWebhookErrors,
|
||||
ReceiveEmailSequenceMailjetWebhookResponses,
|
||||
ReceiveStripeBillingWebhookData,
|
||||
ReceiveStripeBillingWebhookError,
|
||||
ReceiveStripeBillingWebhookErrors,
|
||||
ReceiveStripeBillingWebhookResponses,
|
||||
RefreshJwtTokenData,
|
||||
RefreshJwtTokenError,
|
||||
RefreshJwtTokenErrors,
|
||||
RefreshJwtTokenResponses,
|
||||
RetryEventWebhookAttemptData,
|
||||
RetryEventWebhookAttemptError,
|
||||
RetryEventWebhookAttemptErrors,
|
||||
RetryEventWebhookAttemptResponses,
|
||||
RevalidateWorkspaceContextData,
|
||||
RevalidateWorkspaceContextError,
|
||||
RevalidateWorkspaceContextErrors,
|
||||
RevalidateWorkspaceContextResponse,
|
||||
RevalidateWorkspaceContextResponses,
|
||||
RevokeWorkspaceInviteData,
|
||||
RevokeWorkspaceInviteError,
|
||||
RevokeWorkspaceInviteErrors,
|
||||
RevokeWorkspaceInviteResponse,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
StreamNotificationsData,
|
||||
StreamNotificationsError,
|
||||
StreamNotificationsErrors,
|
||||
StreamNotificationsResponse,
|
||||
StreamNotificationsResponses,
|
||||
UpdateLegacyEntityRowData,
|
||||
UpdateLegacyEntityRowError,
|
||||
UpdateLegacyEntityRowErrors,
|
||||
UpdateLegacyEntityRowResponse,
|
||||
UpdateLegacyEntityRowResponses,
|
||||
UploadAiFileData,
|
||||
UploadAiFileError,
|
||||
UploadAiFileErrors,
|
||||
UploadAiFileResponse,
|
||||
UploadAiFileResponses,
|
||||
ValidateApiKeyData,
|
||||
ValidateApiKeyError,
|
||||
ValidateApiKeyErrors,
|
||||
ValidateApiKeyResponse,
|
||||
ValidateApiKeyResponses,
|
||||
} from "./types.gen";
|
||||
@@ -0,0 +1,631 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from "./client";
|
||||
import { client } from "./client.gen";
|
||||
import type {
|
||||
AcceptWorkspaceInviteData,
|
||||
AcceptWorkspaceInviteErrors,
|
||||
AcceptWorkspaceInviteResponses,
|
||||
CreateAnalyticsEventData,
|
||||
CreateAnalyticsEventErrors,
|
||||
CreateAnalyticsEventResponses,
|
||||
CreateAnalyticsPageViewData,
|
||||
CreateAnalyticsPageViewErrors,
|
||||
CreateAnalyticsPageViewResponses,
|
||||
CreateLegacyEntityRowData,
|
||||
CreateLegacyEntityRowErrors,
|
||||
CreateLegacyEntityRowResponses,
|
||||
CreateLegacyRowRelationshipData,
|
||||
CreateLegacyRowRelationshipErrors,
|
||||
CreateLegacyRowRelationshipResponses,
|
||||
CreateSupportTicketFromInboundEmailData,
|
||||
CreateSupportTicketFromInboundEmailErrors,
|
||||
CreateSupportTicketFromInboundEmailResponses,
|
||||
CreateWorkspaceInviteData,
|
||||
CreateWorkspaceInviteErrors,
|
||||
CreateWorkspaceInviteResponses,
|
||||
DeleteLegacyEntityRowData,
|
||||
DeleteLegacyEntityRowErrors,
|
||||
DeleteLegacyEntityRowResponses,
|
||||
DeleteLegacyRowRelationshipData,
|
||||
DeleteLegacyRowRelationshipErrors,
|
||||
DeleteLegacyRowRelationshipResponses,
|
||||
EnqueueExportArtifactRetentionData,
|
||||
EnqueueExportArtifactRetentionErrors,
|
||||
EnqueueExportArtifactRetentionResponses,
|
||||
GetAccountData,
|
||||
GetAccountErrors,
|
||||
GetAccountResponses,
|
||||
GetAdminAnalyticsOverviewData,
|
||||
GetAdminAnalyticsOverviewErrors,
|
||||
GetAdminAnalyticsOverviewResponses,
|
||||
GetApiDocsData,
|
||||
GetApiDocsResponses,
|
||||
GetApiUsageData,
|
||||
GetApiUsageErrors,
|
||||
GetApiUsageResponses,
|
||||
GetCurrentUserSessionData,
|
||||
GetCurrentUserSessionErrors,
|
||||
GetCurrentUserSessionResponses,
|
||||
GetExportDownloadUrlData,
|
||||
GetExportDownloadUrlErrors,
|
||||
GetExportDownloadUrlResponses,
|
||||
GetExportJobProgressData,
|
||||
GetExportJobProgressErrors,
|
||||
GetExportJobProgressResponses,
|
||||
GetHealthzData,
|
||||
GetHealthzResponses,
|
||||
GetLegacyEntityByNameData,
|
||||
GetLegacyEntityByNameErrors,
|
||||
GetLegacyEntityByNameResponses,
|
||||
GetLegacyEntityRowData,
|
||||
GetLegacyEntityRowErrors,
|
||||
GetLegacyEntityRowResponses,
|
||||
GetLegacyRowRelationshipData,
|
||||
GetLegacyRowRelationshipErrors,
|
||||
GetLegacyRowRelationshipResponses,
|
||||
GetMetricsData,
|
||||
GetMetricsErrors,
|
||||
GetMetricsResponses,
|
||||
GetOpenApiDocumentData,
|
||||
GetOpenApiDocumentResponses,
|
||||
GetPublicAnalyticsOverviewData,
|
||||
GetPublicAnalyticsOverviewErrors,
|
||||
GetPublicAnalyticsOverviewResponses,
|
||||
GetReadyzData,
|
||||
GetReadyzErrors,
|
||||
GetReadyzResponses,
|
||||
GetStatusData,
|
||||
GetStatusErrors,
|
||||
GetStatusResponses,
|
||||
GetV1HealthData,
|
||||
GetV1HealthResponses,
|
||||
ListAccountsData,
|
||||
ListAccountsErrors,
|
||||
ListAccountsResponses,
|
||||
ListLegacyEntityRowsData,
|
||||
ListLegacyEntityRowsErrors,
|
||||
ListLegacyEntityRowsResponses,
|
||||
ListWorkspaceAuthProvidersData,
|
||||
ListWorkspaceAuthProvidersErrors,
|
||||
ListWorkspaceAuthProvidersResponses,
|
||||
ListWorkspaceInvitesData,
|
||||
ListWorkspaceInvitesErrors,
|
||||
ListWorkspaceInvitesResponses,
|
||||
LoginWithPasswordData,
|
||||
LoginWithPasswordErrors,
|
||||
LoginWithPasswordResponses,
|
||||
LogoutApiSessionData,
|
||||
LogoutApiSessionErrors,
|
||||
LogoutApiSessionResponses,
|
||||
PostDeviceRegisterData,
|
||||
PostDeviceRegisterErrors,
|
||||
PostDeviceRegisterResponses,
|
||||
PostDeviceRevokeData,
|
||||
PostDeviceRevokeErrors,
|
||||
PostDeviceRevokeResponses,
|
||||
ReceiveEmailSequenceMailjetWebhookData,
|
||||
ReceiveEmailSequenceMailjetWebhookErrors,
|
||||
ReceiveEmailSequenceMailjetWebhookResponses,
|
||||
ReceiveStripeBillingWebhookData,
|
||||
ReceiveStripeBillingWebhookErrors,
|
||||
ReceiveStripeBillingWebhookResponses,
|
||||
RefreshJwtTokenData,
|
||||
RefreshJwtTokenErrors,
|
||||
RefreshJwtTokenResponses,
|
||||
RetryEventWebhookAttemptData,
|
||||
RetryEventWebhookAttemptErrors,
|
||||
RetryEventWebhookAttemptResponses,
|
||||
RevalidateWorkspaceContextData,
|
||||
RevalidateWorkspaceContextErrors,
|
||||
RevalidateWorkspaceContextResponses,
|
||||
RevokeWorkspaceInviteData,
|
||||
RevokeWorkspaceInviteErrors,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
StreamNotificationsData,
|
||||
StreamNotificationsErrors,
|
||||
StreamNotificationsResponse,
|
||||
StreamNotificationsResponses,
|
||||
UpdateLegacyEntityRowData,
|
||||
UpdateLegacyEntityRowErrors,
|
||||
UpdateLegacyEntityRowResponses,
|
||||
UploadAiFileData,
|
||||
UploadAiFileErrors,
|
||||
UploadAiFileResponses,
|
||||
ValidateApiKeyData,
|
||||
ValidateApiKeyErrors,
|
||||
ValidateApiKeyResponses,
|
||||
} from "./types.gen";
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<
|
||||
TData,
|
||||
ThrowOnError,
|
||||
TResponse
|
||||
> & {
|
||||
/**
|
||||
* You can provide a client instance returned by `createClient()` instead of
|
||||
* individual options. This might be also useful if you want to implement a
|
||||
* custom client.
|
||||
*/
|
||||
client?: Client;
|
||||
/**
|
||||
* You can pass arbitrary values through the `meta` object. This can be
|
||||
* used to access values that aren't defined as part of the SDK function.
|
||||
*/
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const getHealthz = <ThrowOnError extends boolean = false>(options?: Options<GetHealthzData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetHealthzResponses, unknown, ThrowOnError>({ url: "/healthz", ...options });
|
||||
|
||||
export const getV1Health = <ThrowOnError extends boolean = false>(options?: Options<GetV1HealthData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetV1HealthResponses, unknown, ThrowOnError>({ url: "/v1/health", ...options });
|
||||
|
||||
export const getReadyz = <ThrowOnError extends boolean = false>(options?: Options<GetReadyzData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetReadyzResponses, GetReadyzErrors, ThrowOnError>({ url: "/readyz", ...options });
|
||||
|
||||
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetStatusResponses, GetStatusErrors, ThrowOnError>({ url: "/status", ...options });
|
||||
|
||||
export const getMetrics = <ThrowOnError extends boolean = false>(options?: Options<GetMetricsData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetMetricsResponses, GetMetricsErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/metrics",
|
||||
...options,
|
||||
});
|
||||
|
||||
export const getCurrentUserSession = <ThrowOnError extends boolean = false>(options?: Options<GetCurrentUserSessionData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetCurrentUserSessionResponses, GetCurrentUserSessionErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/session/current-user",
|
||||
...options,
|
||||
});
|
||||
|
||||
export const getApiDocs = <ThrowOnError extends boolean = false>(options?: Options<GetApiDocsData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetApiDocsResponses, unknown, ThrowOnError>({ url: "/v1/docs", ...options });
|
||||
|
||||
export const getOpenApiDocument = <ThrowOnError extends boolean = false>(options?: Options<GetOpenApiDocumentData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetOpenApiDocumentResponses, unknown, ThrowOnError>({ url: "/v1/openapi.json", ...options });
|
||||
|
||||
export const loginWithPassword = <ThrowOnError extends boolean = false>(options: Options<LoginWithPasswordData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<LoginWithPasswordResponses, LoginWithPasswordErrors, ThrowOnError>({
|
||||
url: "/v1/auth/login",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const refreshJwtToken = <ThrowOnError extends boolean = false>(options?: Options<RefreshJwtTokenData, ThrowOnError>) =>
|
||||
(options?.client ?? client).post<RefreshJwtTokenResponses, RefreshJwtTokenErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/auth/refresh-token",
|
||||
...options,
|
||||
});
|
||||
|
||||
export const logoutApiSession = <ThrowOnError extends boolean = false>(options?: Options<LogoutApiSessionData, ThrowOnError>) =>
|
||||
(options?.client ?? client).post<LogoutApiSessionResponses, LogoutApiSessionErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/auth/logout",
|
||||
...options,
|
||||
});
|
||||
|
||||
export const validateApiKey = <ThrowOnError extends boolean = false>(options?: Options<ValidateApiKeyData, ThrowOnError>) =>
|
||||
(options?.client ?? client).post<ValidateApiKeyResponses, ValidateApiKeyErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/api-keys/validate",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadAiFile = <ThrowOnError extends boolean = false>(options?: Options<UploadAiFileData, ThrowOnError>) =>
|
||||
(options?.client ?? client).post<UploadAiFileResponses, UploadAiFileErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/ai/upload",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Accepts Mailjet inbound (parse) email webhooks authenticated with HTTP Basic Auth and creates an idempotent support ticket for the matched tenant inbound address.
|
||||
*/
|
||||
export const createSupportTicketFromInboundEmail = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateSupportTicketFromInboundEmailData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).post<CreateSupportTicketFromInboundEmailResponses, CreateSupportTicketFromInboundEmailErrors, ThrowOnError>({
|
||||
security: [{ scheme: "basic", type: "http" }],
|
||||
url: "/v1/support/inbound-email",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const createAnalyticsEvent = <ThrowOnError extends boolean = false>(options: Options<CreateAnalyticsEventData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<CreateAnalyticsEventResponses, CreateAnalyticsEventErrors, ThrowOnError>({
|
||||
security: [{ name: "x-analytics-token", type: "apiKey" }],
|
||||
url: "/v1/analytics/events",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const createAnalyticsPageView = <ThrowOnError extends boolean = false>(options: Options<CreateAnalyticsPageViewData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<CreateAnalyticsPageViewResponses, CreateAnalyticsPageViewErrors, ThrowOnError>({
|
||||
security: [{ name: "x-analytics-token", type: "apiKey" }],
|
||||
url: "/v1/analytics/page-views",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const getPublicAnalyticsOverview = <ThrowOnError extends boolean = false>(options?: Options<GetPublicAnalyticsOverviewData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetPublicAnalyticsOverviewResponses, GetPublicAnalyticsOverviewErrors, ThrowOnError>({
|
||||
security: [{ name: "x-analytics-token", type: "apiKey" }],
|
||||
url: "/v1/analytics/public",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Revalidates a workspace context switch for a tenant/user pair using service-token credentials. The response is a request-ID-correlated, redacted context validity envelope and never includes SSO provider secrets or invite/token material.
|
||||
*/
|
||||
export const revalidateWorkspaceContext = <ThrowOnError extends boolean = false>(options: Options<RevalidateWorkspaceContextData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<RevalidateWorkspaceContextResponses, RevalidateWorkspaceContextErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/workspaces/{tenantId}/context/revalidate",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Lists redacted tenant-scoped OIDC/SAML provider metadata for service-token callers. Responses expose provider status and non-secret endpoints only; client secrets, SAML certificates/private keys, token hashes, invite tokens, and raw metadata documents are omitted.
|
||||
*/
|
||||
export const listWorkspaceAuthProviders = <ThrowOnError extends boolean = false>(options: Options<ListWorkspaceAuthProvidersData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<ListWorkspaceAuthProvidersResponses, ListWorkspaceAuthProvidersErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/workspaces/{tenantId}/auth/providers",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Lists tenant-scoped workspace invites for service-token callers. Response envelopes expose lifecycle timestamps, status, and tokenPrefix only while omitting reusable invite token material.
|
||||
*/
|
||||
export const listWorkspaceInvites = <ThrowOnError extends boolean = false>(options: Options<ListWorkspaceInvitesData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<ListWorkspaceInvitesResponses, ListWorkspaceInvitesErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/workspaces/{tenantId}/invites",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a tenant workspace invite. The response includes the one-time invite token only in the top-level token field; invite envelopes expose only tokenPrefix and never reusable token examples.
|
||||
*/
|
||||
export const createWorkspaceInvite = <ThrowOnError extends boolean = false>(options: Options<CreateWorkspaceInviteData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<CreateWorkspaceInviteResponses, CreateWorkspaceInviteErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/workspaces/{tenantId}/invites",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accepts a workspace invite with a one-time token in the request body. Success and error responses are redacted and never echo the token.
|
||||
*/
|
||||
export const acceptWorkspaceInvite = <ThrowOnError extends boolean = false>(options: Options<AcceptWorkspaceInviteData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<AcceptWorkspaceInviteResponses, AcceptWorkspaceInviteErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/workspaces/{tenantId}/invites/{inviteId}/accept",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Revokes a pending workspace invite by tenant and invite id. Request and error paths do not expose token material.
|
||||
*/
|
||||
export const revokeWorkspaceInvite = <ThrowOnError extends boolean = false>(options: Options<RevokeWorkspaceInviteData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<RevokeWorkspaceInviteResponses, RevokeWorkspaceInviteErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/workspaces/{tenantId}/invites/{inviteId}/revoke",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const getAdminAnalyticsOverview = <ThrowOnError extends boolean = false>(options?: Options<GetAdminAnalyticsOverviewData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetAdminAnalyticsOverviewResponses, GetAdminAnalyticsOverviewErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/analytics/admin",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Streams bounded, tenant/user scoped notification delivery diagnostics as sanitized SSE frames for authenticated service clients. The initial snapshot and reconnect cursor are deterministic for Last-Event-ID resume semantics; the stream does not expose provider payloads, provider message ids, metadata, tokens, request bodies, or raw secrets.
|
||||
*/
|
||||
export const streamNotifications = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<StreamNotificationsData, ThrowOnError, StreamNotificationsResponse>
|
||||
) =>
|
||||
(options?.client ?? client).sse.get<StreamNotificationsResponses, StreamNotificationsErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/notifications/stream",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Lists tenant account metadata and subscription summaries for service-token callers from the canonical API runtime.
|
||||
*/
|
||||
export const listAccounts = <ThrowOnError extends boolean = false>(options?: Options<ListAccountsData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<ListAccountsResponses, ListAccountsErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/accounts",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Looks up one tenant account by id or slug for service-token callers from the canonical API runtime.
|
||||
*/
|
||||
export const getAccount = <ThrowOnError extends boolean = false>(options: Options<GetAccountData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetAccountResponses, GetAccountErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/accounts/{id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Canonical API-host entry point for API usage metadata. The API runtime validates the tenant API key and reads usage state through DB package boundaries.
|
||||
*/
|
||||
export const getApiUsage = <ThrowOnError extends boolean = false>(options?: Options<GetApiUsageData, ThrowOnError>) =>
|
||||
(options?.client ?? client).get<GetApiUsageResponses, GetApiUsageErrors, ThrowOnError>({
|
||||
security: [{ name: "x-api-key", type: "apiKey" }],
|
||||
url: "/v1/usage",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Canonical API-host entry point for Stripe billing lifecycle webhooks. The API runtime preserves the raw body and signature headers and applies subscription updates through API-owned billing services.
|
||||
*/
|
||||
export const receiveStripeBillingWebhook = <ThrowOnError extends boolean = false>(options: Options<ReceiveStripeBillingWebhookData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<ReceiveStripeBillingWebhookResponses, ReceiveStripeBillingWebhookErrors, ThrowOnError>({
|
||||
security: [{ name: "stripe-signature", type: "apiKey" }],
|
||||
url: "/v1/billing/stripe/webhooks",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Accepts Mailjet provider event webhooks for email sequences, authenticated with HTTP Basic Auth. Bounce, blocked, and spam events correlate by provider message id (Message_GUID), mark the sequence delivery bounced, and fail the in-flight run without echoing provider payloads.
|
||||
*/
|
||||
export const receiveEmailSequenceMailjetWebhook = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ReceiveEmailSequenceMailjetWebhookData, ThrowOnError>
|
||||
) =>
|
||||
(options.client ?? client).post<ReceiveEmailSequenceMailjetWebhookResponses, ReceiveEmailSequenceMailjetWebhookErrors, ThrowOnError>({
|
||||
security: [{ scheme: "basic", type: "http" }],
|
||||
url: "/v1/email-sequences/mailjet/webhooks",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const retryEventWebhookAttempt = <ThrowOnError extends boolean = false>(options: Options<RetryEventWebhookAttemptData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<RetryEventWebhookAttemptResponses, RetryEventWebhookAttemptErrors, ThrowOnError>({
|
||||
security: [{ scheme: "basic", type: "http" }],
|
||||
url: "/v1/events/webhooks/attempts/{id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Queues the expired export artifact retention cleanup job for operators. Optional Idempotency-Key deduplicates operator retries; x-request-id remains correlation-only. The response does not expose Redis or storage credentials.
|
||||
*/
|
||||
export const enqueueExportArtifactRetention = <ThrowOnError extends boolean = false>(options?: Options<EnqueueExportArtifactRetentionData, ThrowOnError>) =>
|
||||
(options?.client ?? client).post<EnqueueExportArtifactRetentionResponses, EnqueueExportArtifactRetentionErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/maintenance/export-retention/enqueue",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const listLegacyEntityRows = <ThrowOnError extends boolean = false>(options: Options<ListLegacyEntityRowsData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<ListLegacyEntityRowsResponses, ListLegacyEntityRowsErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/entities/{entity}/rows",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const createLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<CreateLegacyEntityRowData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<CreateLegacyEntityRowResponses, CreateLegacyEntityRowErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/entities/{entity}/rows",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const deleteLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<DeleteLegacyEntityRowData, ThrowOnError>) =>
|
||||
(options.client ?? client).delete<DeleteLegacyEntityRowResponses, DeleteLegacyEntityRowErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/entities/{entity}/rows/{rowId}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const getLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<GetLegacyEntityRowData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetLegacyEntityRowResponses, GetLegacyEntityRowErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/entities/{entity}/rows/{rowId}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const updateLegacyEntityRow = <ThrowOnError extends boolean = false>(options: Options<UpdateLegacyEntityRowData, ThrowOnError>) =>
|
||||
(options.client ?? client).patch<UpdateLegacyEntityRowResponses, UpdateLegacyEntityRowErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/entities/{entity}/rows/{rowId}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const createLegacyRowRelationship = <ThrowOnError extends boolean = false>(options: Options<CreateLegacyRowRelationshipData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<CreateLegacyRowRelationshipResponses, CreateLegacyRowRelationshipErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/relationships",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const deleteLegacyRowRelationship = <ThrowOnError extends boolean = false>(options: Options<DeleteLegacyRowRelationshipData, ThrowOnError>) =>
|
||||
(options.client ?? client).delete<DeleteLegacyRowRelationshipResponses, DeleteLegacyRowRelationshipErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/relationships/{id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Authorize with either the configured service token bearer credential or a stored API key supplied in x-api-key. Stored API keys are tenant-bound and must include an entity permission for the resolved tenant/entity plus the documented CRUD operation.
|
||||
*/
|
||||
export const getLegacyRowRelationship = <ThrowOnError extends boolean = false>(options: Options<GetLegacyRowRelationshipData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetLegacyRowRelationshipResponses, GetLegacyRowRelationshipErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/relationships/{id}",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a short-lived signed GET URL for a completed, unexpired export job. Authorize with either the configured service token bearer credential or a tenant-bound stored API key supplied in x-api-key; stored API keys are scoped to their own tenant and this route does not publish CRUD entity-scope claims. Responses never include export artifact bucket/key values, storage credentials, signed URL internals beyond the URL envelope, payload filters, or object bodies.
|
||||
*/
|
||||
export const getExportDownloadUrl = <ThrowOnError extends boolean = false>(options: Options<GetExportDownloadUrlData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetExportDownloadUrlResponses, GetExportDownloadUrlErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/exports/{exportJobId}/download-url",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Poll a tenant-scoped export job's progress envelope. Authorize with either the configured service-token bearer credential or a tenant-bound stored API key supplied in x-api-key; stored API keys are scoped to their own tenant. Responses contain progress fields only (status, percent, rowCount) — no artifact URLs, no compliance-pause internals, no payload filters.
|
||||
*/
|
||||
export const getExportJobProgress = <ThrowOnError extends boolean = false>(options: Options<GetExportJobProgressData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetExportJobProgressResponses, GetExportJobProgressErrors, ThrowOnError>({
|
||||
security: [
|
||||
{ scheme: "bearer", type: "http" },
|
||||
{ name: "x-api-key", type: "apiKey" },
|
||||
],
|
||||
url: "/v1/exports/{exportJobId}/progress",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Register or update a mobile push token (APNs / FCM / WebPush) for the authenticated principal. Requires a service-token bearer credential; the body's userId + tenantId are reconciled downstream against the authenticated principal. Tokens are upserted by their globally unique value so cold-start replays are idempotent.
|
||||
*/
|
||||
export const postDeviceRegister = <ThrowOnError extends boolean = false>(options: Options<PostDeviceRegisterData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<PostDeviceRegisterResponses, PostDeviceRegisterErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/devices/register",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Revoke a mobile push token. Requires a service-token bearer credential; the handler revokes by token hash and never echoes the raw token in the response.
|
||||
*/
|
||||
export const postDeviceRevoke = <ThrowOnError extends boolean = false>(options: Options<PostDeviceRevokeData, ThrowOnError>) =>
|
||||
(options.client ?? client).post<PostDeviceRevokeResponses, PostDeviceRevokeErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/devices/revoke",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
export const getLegacyEntityByName = <ThrowOnError extends boolean = false>(options: Options<GetLegacyEntityByNameData, ThrowOnError>) =>
|
||||
(options.client ?? client).get<GetLegacyEntityByNameResponses, GetLegacyEntityByNameErrors, ThrowOnError>({
|
||||
security: [{ scheme: "bearer", type: "http" }],
|
||||
url: "/v1/entities/{entity}",
|
||||
...options,
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @toolctrl/api-client-ts
|
||||
*
|
||||
* Official TypeScript SDK for the toolctrl boilerplate REST API.
|
||||
*
|
||||
* The public surface is a thin Higher-Level-Wrapper around the @hey-api/openapi-ts
|
||||
* generated client. The wrapper adds auth header injection, retry with exponential
|
||||
* backoff (honoring Retry-After), idempotency-key auto-generation on mutating
|
||||
* requests, an async pagination iterator, and an HMAC webhook signature verifier.
|
||||
*
|
||||
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
|
||||
*/
|
||||
|
||||
export {
|
||||
createToolctrlClient,
|
||||
ToolctrlApiError,
|
||||
ToolctrlNetworkError,
|
||||
type ToolctrlClient,
|
||||
type ToolctrlClientConfig,
|
||||
type ToolctrlAuthConfig,
|
||||
type ToolctrlRetryConfig,
|
||||
type ToolctrlIdempotencyConfig,
|
||||
type PaginatedResponse,
|
||||
type PaginateOptions,
|
||||
} from "./client.js";
|
||||
|
||||
export { verifyWebhookSignature, signWebhookPayload, type VerifyWebhookOptions, type SignWebhookOptions } from "./webhooks.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { verifyWebhookSignature, signWebhookPayload, DEFAULT_REPLAY_WINDOW_MS } from "./webhooks.js";
|
||||
|
||||
const SECRET = "whsec_test_super_secret";
|
||||
const PAYLOAD = `{"event":"contact.created","data":{"id":"c_42"}}`;
|
||||
const TIMESTAMP = 1_700_000_000;
|
||||
|
||||
describe("signWebhookPayload", () => {
|
||||
it("produces a deterministic hex digest", () => {
|
||||
const sigA = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const sigB = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
expect(sigA).toBe(sigB);
|
||||
expect(sigA).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("differs when the timestamp changes", () => {
|
||||
const sig1 = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const sig2 = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP + 1 });
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
it("rejects empty secrets", () => {
|
||||
expect(() => signWebhookPayload({ secret: "", payload: PAYLOAD, timestamp: TIMESTAMP })).toThrow(/secret/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyWebhookSignature", () => {
|
||||
const now = () => TIMESTAMP * 1000 + 30_000; // 30s after issue
|
||||
|
||||
it("accepts a valid signature within the replay window", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a tampered payload", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const tampered = PAYLOAD.replace("c_42", "c_99");
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: tampered, signature, timestamp: TIMESTAMP, now })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const broken = signature.replace(/.$/, signature.slice(-1) === "a" ? "b" : "a");
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: broken, timestamp: TIMESTAMP, now })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong secret", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
expect(verifyWebhookSignature({ secret: "other_secret", payload: PAYLOAD, signature, timestamp: TIMESTAMP, now })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a signature outside the replay window", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const farFuture = () => TIMESTAMP * 1000 + DEFAULT_REPLAY_WINDOW_MS + 1000;
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now: farFuture })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a clock-skewed signature in the past within the window", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const slightlyEarly = () => TIMESTAMP * 1000 - 30_000;
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now: slightlyEarly })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts signatures with `sha256=` prefix", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: `sha256=${signature}`, timestamp: TIMESTAMP, now })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on malformed input rather than throwing", () => {
|
||||
expect(verifyWebhookSignature({ secret: "", payload: PAYLOAD, signature: "x", timestamp: TIMESTAMP, now })).toBe(false);
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: "", timestamp: TIMESTAMP, now })).toBe(false);
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: "not-hex", timestamp: TIMESTAMP, now })).toBe(false);
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature: "ff", timestamp: "not-a-number", now })).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies Buffer payloads identically", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
expect(
|
||||
verifyWebhookSignature({
|
||||
secret: SECRET,
|
||||
payload: Buffer.from(PAYLOAD, "utf8"),
|
||||
signature,
|
||||
timestamp: TIMESTAMP,
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("respects a custom replay window override", () => {
|
||||
const signature = signWebhookPayload({ secret: SECRET, payload: PAYLOAD, timestamp: TIMESTAMP });
|
||||
const past = () => TIMESTAMP * 1000 + 90_000;
|
||||
// Default window (5min) accepts it.
|
||||
expect(verifyWebhookSignature({ secret: SECRET, payload: PAYLOAD, signature, timestamp: TIMESTAMP, now: past })).toBe(true);
|
||||
// 30-second window rejects it.
|
||||
expect(
|
||||
verifyWebhookSignature({
|
||||
secret: SECRET,
|
||||
payload: PAYLOAD,
|
||||
signature,
|
||||
timestamp: TIMESTAMP,
|
||||
now: past,
|
||||
replayWindowMs: 30_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Webhook signature verification & signing helpers.
|
||||
*
|
||||
* Mirrors the server-side signing logic used by the workflow engine: HMAC-SHA256
|
||||
* over `${timestamp}.${payload}` with a 5-minute anti-replay window. Signatures are
|
||||
* compared in constant time to defeat timing side-channels.
|
||||
*
|
||||
* Refs: docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/** Default replay-protection window: 5 minutes in milliseconds. */
|
||||
export const DEFAULT_REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface VerifyWebhookOptions {
|
||||
/** Shared secret used to sign the payload server-side. */
|
||||
secret: string;
|
||||
/** Raw request body, exactly as received (do not re-serialize). */
|
||||
payload: string | Buffer;
|
||||
/** Hex-encoded HMAC-SHA256 signature delivered via header (e.g. `X-Toolctrl-Signature`). */
|
||||
signature: string;
|
||||
/** Unix timestamp in seconds when the webhook was emitted. */
|
||||
timestamp: number | string;
|
||||
/** Optional override for the replay window in ms. Defaults to 5 minutes. */
|
||||
replayWindowMs?: number;
|
||||
/** Optional override for the reference time (used by tests). Defaults to `Date.now()`. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface SignWebhookOptions {
|
||||
secret: string;
|
||||
payload: string | Buffer;
|
||||
timestamp: number | string;
|
||||
}
|
||||
|
||||
function normalizeSignature(value: string): string {
|
||||
// Strip optional algorithm prefix like "sha256=" or "v1=".
|
||||
const eq = value.indexOf("=");
|
||||
if (eq >= 0 && eq < 16) {
|
||||
return value.slice(eq + 1).trim();
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function computeSignatureHex(secret: string, payload: string | Buffer, timestamp: number | string): string {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
hmac.update(`${timestamp}.`);
|
||||
hmac.update(payload);
|
||||
return hmac.digest("hex");
|
||||
}
|
||||
|
||||
/** Sign a webhook payload exactly the way the server does — used by tests and by senders. */
|
||||
export function signWebhookPayload(options: SignWebhookOptions): string {
|
||||
if (!options.secret) throw new Error("signWebhookPayload: secret is required");
|
||||
return computeSignatureHex(options.secret, options.payload, options.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a webhook signature in constant time.
|
||||
*
|
||||
* Returns `true` if the signature is valid and the timestamp is within the
|
||||
* replay-protection window. Returns `false` for malformed, expired, or tampered
|
||||
* payloads without throwing — callers should reject with HTTP 403.
|
||||
*/
|
||||
export function verifyWebhookSignature(options: VerifyWebhookOptions): boolean {
|
||||
if (!options.secret || !options.signature || options.timestamp === undefined || options.timestamp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tsNumber = typeof options.timestamp === "string" ? Number(options.timestamp) : options.timestamp;
|
||||
if (!Number.isFinite(tsNumber)) return false;
|
||||
|
||||
const now = (options.now ?? Date.now)();
|
||||
const window = options.replayWindowMs ?? DEFAULT_REPLAY_WINDOW_MS;
|
||||
const deltaMs = Math.abs(now - tsNumber * 1000);
|
||||
if (deltaMs > window) return false;
|
||||
|
||||
const expectedHex = computeSignatureHex(options.secret, options.payload, options.timestamp);
|
||||
const providedHex = normalizeSignature(options.signature);
|
||||
|
||||
if (expectedHex.length !== providedHex.length) return false;
|
||||
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(expectedHex, "hex"), Buffer.from(providedHex, "hex"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
client: "src/client.ts",
|
||||
webhooks: "src/webhooks.ts",
|
||||
},
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
target: "node18",
|
||||
external: [],
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["src/generated/**", "src/__integration__/**", "node_modules/**"],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user