Skip to content

Commit a8b2d56

Browse files
Kevin-layerVclaudejustin-layerv
authored
feat!: align types and client with latest API spec (#14)
## Summary - Update all types to match current OpenAPI spec (breaking changes) - Create endpoint moved from `/v1/qurl` to `/v1/qurls` - `CreateInput.description` renamed to `label`; `metadata` removed - `QURL` response: removed `one_time_use`, `max_sessions`, `qurl_link`; added `tags`, `custom_domain`, `qurl_count`; `qurls` wire field mapped to `access_tokens` (see note below); `status` narrowed to `"active" | "revoked"` (see note — TS consumers pattern-matching on `"consumed"` / `"expired"` get a **compile** error) - `UpdateInput`: removed `access_policy`; added `tags` - `MintInput` expanded with full per-QURL settings - New `batchCreate()` method for `POST /v1/qurls/batch` - `AccessPolicy` now includes `ai_agent_policy` - `Quota.rate_limits` adds `max_expiry_seconds`; `active_qurls_percent` now nullable ## Migration notes No live consumers exist yet (the SDK is still pre-1.0 / pre-first-release), so this is a heads-up rather than an upgrade-on-fire migration guide. For the first consumers coming online after this lands: **⚠️ `batchCreate()` does not throw on partial/total failure** The new batch endpoint uses an HTTP 400 "passthrough" pattern: when every item in a batch fails validation, the API returns 400 with a populated `BatchCreateOutput` body, and the SDK surfaces that body as a normal return value rather than throwing. This is a deliberate design choice — throwing would swallow the per-item error details callers actually need to fix a bad batch. **Consumers who only use `try/catch` for error handling will silently miss partial failures.** You must also inspect `result.failed` and iterate `result.results`: ```ts const result = await client.batchCreate({ items: [...] }); if (result.failed > 0) { for (const r of result.results) { if (!r.success) { console.error(`items[${r.index}]: ${r.error.code} - ${r.error.message}`); } } } ``` Non-400 error statuses (401, 403, 429, 5xx, and unexpected 400 body shapes) still throw the appropriate `QURLError` subclass. **`CreateInput.description` → `label`, no absolute-expiry on create** ```ts // Before await client.create({ target_url: "https://example.com", description: "Alice from Acme", expires_at: "2026-04-01T00:00:00Z", }); // After await client.create({ target_url: "https://example.com", label: "Alice from Acme", expires_in: "24h", }); ``` Per the OpenAPI spec (`CreateQurlRequest`), the create endpoint only accepts relative `expires_in`, not absolute `expires_at`. To set an absolute expiry at creation time, use the minimum practical initial expiry and immediately update: ```ts // Minimum practical initial expiry ("1m") minimizes the window // during which the QURL is live with the wrong expiry. const qurl = await client.create({ target_url: "https://example.com", expires_in: "1m", }); await client.update(qurl.resource_id, { expires_at: "2026-04-01T00:00:00Z" }); ``` There's an unavoidable ~1-second window between `create()` and `update()` where the QURL is live with the initial 1-minute expiry. For hard-synchronous absolute-expiry guarantees, gate access behind your own authorization layer until after the update completes. **`UpdateInput.access_policy` removed** Access policy is now set only at create time. Per the OpenAPI `UpdateQurlRequest` schema, the update surface only accepts `extend_by`, `expires_at`, `tags`, `description`. Move any `access_policy` usage from `update()` to `create()`. **`QURL.custom_domain` — deliberate `string | null` vs `string | undefined` asymmetry** Read-side is `string | null` (matching the OpenAPI `nullable: true` declaration on `ResourceData.custom_domain`). Write-side on `CreateInput.custom_domain` is `string | undefined` (absent = "don't set"). The asymmetry is intentional: JSON `null` and an absent field have different semantics, and the API surface uses the convention across reads and writes. **`Quota.active_qurls_percent` is now `number | null`** Previously always a number; now returns `null` when the plan has unlimited active QURLs. Callers doing arithmetic on this field will get a runtime error if they don't handle `null`: ```ts // Before const pct = quota.usage.active_qurls_percent; // always number // After const pct = quota.usage.active_qurls_percent ?? 0; // null when unlimited ``` **`QURL.status` type narrowed to resource-level values** `status` on the `QURL` type was narrowed from `"active" | "consumed" | "revoked" | "expired"` to `"active" | "revoked"`. The SDK's `QURL` type maps to the API's `QurlData` schema, which is the resource-level response envelope (groups access tokens under a protected URL). The API spec defines `status: enum [active, revoked]` on this schema — `"consumed"` and `"expired"` are per-token statuses that appear on individual access tokens within the `qurls` array, not on the parent container. Any TypeScript consumer pattern-matching on `"consumed"` or `"expired"` at this level will get a compile error. **`CreateInput.metadata` removed** The `metadata` field was removed from `CreateInput` — the current API spec does not include it on `CreateQurlRequest`. If you were passing `metadata`, the field is no longer accepted. There is no direct replacement in the current spec. **`list()` `limit: 0` now rejected client-side** Previously `limit: 0` was passed through to the server. The new client-side validation enforces the OpenAPI spec's `minimum: 1, maximum: 100` range and throws `ValidationError` before the request. Callers using `limit: 0` to mean "use server default" should omit `limit` entirely instead. **`qurls` → `access_tokens` field mapping (load-bearing)** The API currently returns nested access tokens under the wire field name `qurls`. The SDK's `QURL` type uses `access_tokens` as the consumer-facing property name — the rename is performed by `mapQurlsField` on every `get()`, `list()`, and `update()` response. This mapping is **load-bearing** (the API has not yet migrated the wire name), not cosmetic back-compat. The method also handles the future case where the API sends both fields during a migration: `access_tokens` wins, `qurls` is dropped, and a debug log fires with both counts so operators can track the transition. ## Test plan - [x] `npm run build` compiles with no type errors - [x] `npm run lint` passes clean - [x] `npm run format:check` passes clean - [x] `npm test` — 171 tests passing - [x] New tests for batch create (207 Multi-Status, shape guards, collect-all validation, partial-failure 400 path, request_id propagation, index-attribution), client-side validation boundaries, untyped-JS edge cases, and retry policy per HTTP method 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: Justin <[email protected]>
1 parent 7ee3b22 commit a8b2d56

10 files changed

Lines changed: 6871 additions & 733 deletions

File tree

README.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const client = new QURLClient({ apiKey: 'lv_live_xxx' });
3131
const result = await client.create({
3232
target_url: 'https://api.example.com/data',
3333
expires_in: '24h',
34-
description: 'API access for agent',
34+
label: 'API access for agent',
3535
});
3636
console.log(result.qurl_link);
3737

@@ -49,7 +49,7 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
4949
| `apiKey` | Yes ||
5050
| `baseUrl` | No | `https://api.layerv.ai` |
5151
| `maxRetries` | No | `3` |
52-
| `timeout` | No | `30000` (ms) |
52+
| `timeout` | No | `30000` (ms) *per attempt*, not total |
5353
| `fetch` | No | `globalThis.fetch` |
5454
| `userAgent` | No | `qurl-typescript/<version>` |
5555
| `debug` | No | `false` |
@@ -59,6 +59,7 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
5959
| Method | Description |
6060
|--------|-------------|
6161
| `create(input)` | Create a protected link |
62+
| `batchCreate(input)` | Create up to 100 protected links in one request |
6263
| `get(id)` | Get qURL details |
6364
| `list(input?)` | List qURLs (single page) |
6465
| `listAll(input?)` | Iterate all qURLs (auto-paginating) |
@@ -69,6 +70,35 @@ console.log(`Access granted to ${access.target_url} for ${access.access_grant?.e
6970
| `resolve(input)` | Resolve token + open firewall |
7071
| `getQuota()` | Get quota/usage info |
7172

73+
### `batchCreate(input)`
74+
75+
Create up to 100 qURLs in a single request. **Does not throw on partial or total failure** — per-item errors are returned in the `results` array, so `try/catch` alone won't surface them. Always inspect `result.failed` and iterate `result.results`:
76+
77+
```typescript
78+
const result = await client.batchCreate({
79+
items: [
80+
{ target_url: 'https://api.example.com/data', expires_in: '24h' },
81+
{ target_url: 'https://api.example.com/admin', expires_in: '1h' },
82+
],
83+
});
84+
85+
if (result.failed > 0) {
86+
for (const r of result.results) {
87+
if (!r.success) {
88+
console.error(`items[${r.index}]: ${r.error.code} - ${r.error.message}`);
89+
}
90+
}
91+
}
92+
```
93+
94+
Non-400 errors (401, 403, 429, 5xx, and unexpected 400 body shapes) still throw the appropriate `QURLError` subclass.
95+
96+
**Slimmer per-item shape**`BatchItemSuccess` returns `{ resource_id, qurl_link, qurl_site, expires_at? }` per item. Unlike single `client.create()`, the batch response intentionally **omits `qurl_id` and `label`** to keep the payload compact. If you migrate a per-item `create()` loop to `batchCreate` and rely on `qurl_id` for downstream addressing, fetch each via `client.get(resource_id)` after the batch (or stay on the single-create path).
97+
98+
**Result ordering**`result.results` is **not** guaranteed to be sorted by `index`. Each entry's `index` field carries the position in the original `items` array, so build per-input-position state by keying on `r.index` (e.g., `for (const r of result.results) { byInputIndex[r.index] = r; }`) rather than relying on iteration order.
99+
100+
**Out-of-range or duplicate `index` values** — the SDK throws `QURLError` (`code: "unexpected_response"`) on either condition, since both indicate server misbehavior that would silently break per-item attribution (a `Map` keyed on `r.index` would last-write-wins, an out-of-range index would attribute to a non-existent slot).
101+
72102
## Error Handling
73103

74104
All API errors throw typed error subclasses, so you can catch specific failure modes:
@@ -144,10 +174,18 @@ The client automatically retries failed requests with exponential backoff:
144174
- **GET/DELETE**: Retries on 429, 502, 503, 504
145175
- **POST/PATCH**: Retries only on 429 (to avoid duplicate side effects)
146176
- **Network errors**: Always retried
147-
- **429 responses**: Honors `Retry-After` header
177+
- **`Retry-After` header**: Honored on 429 and 503 responses (RFC 7231 §7.1.3). Currently the SDK only parses **delta-seconds** values (e.g. `Retry-After: 30`); HTTP-date values (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`) silently fall back to exponential backoff. Tracked in [#61](https://github.com/layervai/qurl-typescript/issues/61).
148178

149179
Configure with `maxRetries` (default: 3). Set to `0` to disable.
150180

181+
> **Worst-case latency**: `timeout` is enforced per *attempt*, not for the whole request. Total worst-case latency is roughly `timeout × (maxRetries + 1) + sum(retry delays)`. Operators tuning `timeout` should account for this when sizing health-check budgets.
182+
183+
## Versioning & breaking changes
184+
185+
This SDK is pre-1.0; breaking changes between minor versions are possible until the API surface stabilizes. Significant changes are called out in [`CHANGELOG.md`](CHANGELOG.md) and in the corresponding GitHub release notes.
186+
187+
When upgrading, check the release notes for migration guidance — recent breaking changes have included field renames (`description``label` on create), removed fields (`metadata`), narrowed type unions (`QURL.status`), and endpoint relocations (`/v1/qurl``/v1/qurls`).
188+
151189
## License
152190

153191
MIT

contract/openapi.snapshot.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ paths:
1919
/v1/qurls:
2020
get: {}
2121
post: {}
22+
/v1/qurls/batch:
23+
post: {}
2224
/v1/qurls/{id}:
2325
get: {}
2426
patch: {}

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/test-helpers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@ type MockResponse = {
1010
status: number;
1111
body?: unknown;
1212
headers?: Record<string, string>;
13+
/** Override the synthesized statusText (e.g. `""` for HTTP/2-style empty reason-phrases). */
14+
statusText?: string;
1315
};
1416

1517
function buildResponse(response: MockResponse): Response {
1618
const ok = response.status >= 200 && response.status < 300;
1719
return {
1820
ok,
1921
status: response.status,
20-
// statusText mirrors `ok` (not just status === 200) so 201/204
22+
// Default mirrors `ok` (not just status === 200) so 201/204
2123
// successes don't render as "Error" to assertions that inspect it.
22-
statusText: ok ? "OK" : "Error",
24+
// Tests can pass `statusText: ""` to simulate HTTP/2.
25+
statusText: response.statusText ?? (ok ? "OK" : "Error"),
2326
headers: new Headers(response.headers ?? {}),
2427
json: () => Promise.resolve(response.body),
2528
text: () => Promise.resolve(JSON.stringify(response.body)),

0 commit comments

Comments
 (0)