Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,243 @@
|
||||
# TypeScript SDK — `@toolctrl/api-client-ts`
|
||||
|
||||
The official TypeScript SDK for the toolctrl boilerplate REST API. Pairs a
|
||||
`@hey-api/openapi-ts`-generated typed client with a hand-written Higher-Level
|
||||
wrapper that handles auth, retry, idempotency, pagination, and webhook
|
||||
signature verification.
|
||||
|
||||
- Source: `packages/api-client-ts/`
|
||||
- Types-only sibling: `packages/api-types/` (`@toolctrl/api-types`)
|
||||
- Spec: `docs/improvement-plan/08-mobile-cli-storybook-sdk/ts-sdk.md`
|
||||
|
||||
## Installation
|
||||
|
||||
The SDK is currently a workspace package. Add it as a peer / dependency in any
|
||||
consumer inside this monorepo:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"dependencies": {
|
||||
"@toolctrl/api-client-ts": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After the SDK is published to the internal registry it becomes:
|
||||
|
||||
```bash
|
||||
npm install @toolctrl/api-client-ts @toolctrl/api-types
|
||||
```
|
||||
|
||||
The package targets Node 18+ and modern browsers (uses native `fetch` and
|
||||
`crypto.randomUUID`).
|
||||
|
||||
## 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,
|
||||
});
|
||||
|
||||
// Typed REST call — falls back to `unknown` if T is omitted.
|
||||
const me = await client.request<{ id: string; email: string }>({
|
||||
method: "GET",
|
||||
path: "/v1/session/current-user",
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Three modes are supported via the `auth` field.
|
||||
|
||||
```ts
|
||||
// Bearer token — `Authorization: Bearer <token>` on every call.
|
||||
createToolctrlClient({ baseUrl, auth: { bearerToken: jwt } });
|
||||
|
||||
// API key — sent as `X-API-Key: <key>`.
|
||||
createToolctrlClient({ baseUrl, auth: { apiKey: process.env.TOOLCTRL_API_KEY! } });
|
||||
|
||||
// Async getter — used for refresh-aware JWT flows. Resolved before every
|
||||
// outgoing request; the result is cached until `client.refreshAuth()` is
|
||||
// called or the getter returns a new value.
|
||||
createToolctrlClient({
|
||||
baseUrl,
|
||||
auth: { getToken: () => loadTokenFromKeychain() },
|
||||
});
|
||||
```
|
||||
|
||||
Refresh the cached bearer manually if the consumer detects token expiry
|
||||
between requests:
|
||||
|
||||
```ts
|
||||
await client.refreshAuth();
|
||||
```
|
||||
|
||||
Token logging: the SDK never logs the secret material — only error responses
|
||||
including the `x-request-id` for correlation.
|
||||
|
||||
## Retry semantics
|
||||
|
||||
By default the client retries on `408`, `425`, `429`, `500`, `502`, `503`,
|
||||
`504`, and network errors with exponential backoff (base = 1s, max = 30s,
|
||||
±250ms jitter). `Retry-After` headers — both seconds and HTTP-date forms —
|
||||
are honored verbatim and capped at the configured `maxDelayMs`.
|
||||
|
||||
```ts
|
||||
createToolctrlClient({
|
||||
baseUrl,
|
||||
retry: {
|
||||
maxAttempts: 5, // default 5 (initial request counts as attempt 1)
|
||||
baseDelayMs: 1000, // default 1000
|
||||
maxDelayMs: 30000, // default 30000
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Non-retryable 4xx responses surface as `ToolctrlApiError`:
|
||||
|
||||
```ts
|
||||
import { ToolctrlApiError } from "@toolctrl/api-client-ts";
|
||||
|
||||
try {
|
||||
await client.request({ method: "GET", path: "/v1/missing" });
|
||||
} catch (err) {
|
||||
if (err instanceof ToolctrlApiError) {
|
||||
console.error(err.status, err.requestId, err.body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exhausted retries throw `ToolctrlNetworkError` with the underlying `cause`.
|
||||
|
||||
## Idempotency
|
||||
|
||||
POST / PUT / PATCH requests automatically receive an `Idempotency-Key` header
|
||||
generated via `crypto.randomUUID()`. Callers can override with a deterministic
|
||||
key or opt out entirely.
|
||||
|
||||
```ts
|
||||
// Caller-supplied key — useful for retrying the same logical write.
|
||||
await client.request({
|
||||
method: "POST",
|
||||
path: "/v1/orders",
|
||||
body: { sku: "x" },
|
||||
idempotencyKey: "order_2026_05_17_abc",
|
||||
});
|
||||
|
||||
// Disable auto-generation globally.
|
||||
createToolctrlClient({ baseUrl, idempotency: { autoGenerate: false } });
|
||||
```
|
||||
|
||||
GET / HEAD / DELETE requests never receive an auto-generated key.
|
||||
|
||||
## Pagination
|
||||
|
||||
The async iterator `client.paginate<T>(path, opts)` follows whichever pagination
|
||||
style the upstream endpoint emits:
|
||||
|
||||
- `nextCursor` (opaque cursor)
|
||||
- `nextPageToken` (cursor under a different name)
|
||||
- `nextPage` (offset)
|
||||
|
||||
```ts
|
||||
for await (const contact of client.paginate<{ id: string; email: string }>("/v1/contacts", {
|
||||
query: { search: "@acme.com" },
|
||||
})) {
|
||||
console.log(contact.id, contact.email);
|
||||
}
|
||||
```
|
||||
|
||||
A safety stop (`maxPages`, default 1000) prevents runaway iteration when an
|
||||
endpoint never emits a terminating null cursor.
|
||||
|
||||
## Webhook signature verification
|
||||
|
||||
Server-side webhook handlers should `verifyWebhookSignature` before reading any
|
||||
payload data. The helper uses HMAC-SHA256 plus constant-time compare and
|
||||
applies a 5-minute replay window.
|
||||
|
||||
```ts
|
||||
import { verifyWebhookSignature } from "@toolctrl/api-client-ts";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const signature = request.headers.get("X-Toolctrl-Signature");
|
||||
const timestamp = request.headers.get("X-Toolctrl-Timestamp");
|
||||
const payload = await request.text();
|
||||
|
||||
if (
|
||||
!signature ||
|
||||
!timestamp ||
|
||||
!verifyWebhookSignature({
|
||||
secret: process.env.TOOLCTRL_WEBHOOK_SECRET!,
|
||||
payload,
|
||||
signature,
|
||||
timestamp,
|
||||
})
|
||||
) {
|
||||
return new Response("forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// ...handle the event
|
||||
}
|
||||
```
|
||||
|
||||
The companion `signWebhookPayload` is exported for tests and webhook senders
|
||||
that produce signatures in JS land:
|
||||
|
||||
```ts
|
||||
import { signWebhookPayload } from "@toolctrl/api-client-ts";
|
||||
|
||||
const signature = signWebhookPayload({
|
||||
secret: process.env.TOOLCTRL_WEBHOOK_SECRET!,
|
||||
payload,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
```
|
||||
|
||||
## Sentry trace propagation
|
||||
|
||||
If the calling context provides `sentry-trace` and/or `baggage` headers, pass
|
||||
them through via `sentry`:
|
||||
|
||||
```ts
|
||||
createToolctrlClient({
|
||||
baseUrl,
|
||||
sentry: {
|
||||
trace: getActiveSentryTrace(),
|
||||
baggage: getSentryBaggage(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The SDK forwards both headers verbatim, enabling end-to-end Sentry trace
|
||||
linking between consumer and toolctrl API.
|
||||
|
||||
## Regenerating the client
|
||||
|
||||
When the upstream API changes, regenerate locally:
|
||||
|
||||
```bash
|
||||
pnpm --filter @toolctrl/api-client-ts generate-spec
|
||||
pnpm --filter @toolctrl/api-client-ts generate
|
||||
# Or both in one shot:
|
||||
pnpm --filter @toolctrl/api-client-ts generate:all
|
||||
```
|
||||
|
||||
The CI workflow `.github/workflows/sdk-codegen.yml` regenerates on every PR
|
||||
that touches `apps/api/src/openapi.ts` or the SDK package and fails the merge
|
||||
if the committed `src/generated/` tree drifts from the captured OpenAPI
|
||||
document.
|
||||
|
||||
## Error model
|
||||
|
||||
| Class | When it fires | Notable fields |
|
||||
| ---------------------- | ------------------------------------------- | --------------------------------------------- |
|
||||
| `ToolctrlApiError` | API returned a non-retryable HTTP error | `status`, `body`, `requestId` (`x-request-id`) |
|
||||
| `ToolctrlNetworkError` | Network failure after all retries exhausted | `cause` (the underlying transport error) |
|
||||
|
||||
Both inherit from the built-in `Error` so existing `instanceof Error` checks
|
||||
continue to work.
|
||||
Reference in new issue
Block a user