Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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,
},
});