@zerotal/arch 1.7.3 → 1.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -2
- package/README.md +4 -2
- package/api-surface.md +4 -4
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +165 -1
- package/docs/client/index.md +277 -70
- package/docs/commands.md +4 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +46 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +2 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +199 -0
- package/docs/flow/index.md +20 -20
- package/docs/flow/layouts.md +35 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +12 -7
- package/docs/inertia/props.md +22 -22
- package/docs/inertia/rendering.md +86 -7
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +36 -8
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +3 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/index.ts +25 -1
- package/src/tools/searchDocs.ts +7 -0
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/client/files.md
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Client File Transfers
|
|
3
|
-
description: Uploading and downloading binary payloads.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# File uploads & downloads
|
|
7
|
-
|
|
8
|
-
The client encodes plain objects as JSON, which is the right default until the
|
|
9
|
-
payload is binary. Recognising the difference is the whole of file handling here:
|
|
10
|
-
on the way out, a body the platform already knows how to encode is passed through
|
|
11
|
-
untouched; on the way back, you name the shape you expect.
|
|
12
|
-
|
|
13
|
-
## Uploading
|
|
14
|
-
|
|
15
|
-
A body that is `FormData`, `Blob`, `File`, `URLSearchParams`, `ArrayBuffer`, or a
|
|
16
|
-
string is sent as-is with no JSON encoding, which lets `fetch` set the correct
|
|
17
|
-
`Content-Type` — including the multipart boundary, a value that cannot be written
|
|
18
|
-
by hand:
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
// in any frontend module
|
|
22
|
-
const form = new FormData();
|
|
23
|
-
form.append("avatar", file);
|
|
24
|
-
await api.post("/api/avatars", form);
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
| Body | Sent as |
|
|
28
|
-
| ----------------- | ------------------------------------------- |
|
|
29
|
-
| Plain object | JSON, with `Content-Type: application/json` |
|
|
30
|
-
| `FormData` | Multipart, boundary set by the platform |
|
|
31
|
-
| `File` / `Blob` | Raw bytes |
|
|
32
|
-
| `URLSearchParams` | Form-encoded |
|
|
33
|
-
| `ArrayBuffer` | Raw bytes |
|
|
34
|
-
| String | Sent verbatim |
|
|
35
|
-
|
|
36
|
-
Do not set `Content-Type` yourself for a `FormData` upload. A hand-written header
|
|
37
|
-
has no boundary parameter, and the server then fails to parse a body that is
|
|
38
|
-
otherwise perfectly formed — a confusing failure worth avoiding by simply leaving
|
|
39
|
-
the header alone.
|
|
40
|
-
|
|
41
|
-
To send a file alongside ordinary fields, put everything in the `FormData`;
|
|
42
|
-
mixing a JSON body and a file in one request is not possible:
|
|
43
|
-
|
|
44
|
-
```ts
|
|
45
|
-
const form = new FormData();
|
|
46
|
-
form.append("title", "Quarterly report");
|
|
47
|
-
form.append("document", file);
|
|
48
|
-
await api.post("/api/reports", form);
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Downloading
|
|
52
|
-
|
|
53
|
-
Responses are parsed as JSON unless you say otherwise. `responseType` names the
|
|
54
|
-
shape you want:
|
|
55
|
-
|
|
56
|
-
| `responseType` | Returns | Reach for it when |
|
|
57
|
-
| --------------- | -------------------- | ------------------------------------ |
|
|
58
|
-
| `"auto"` | Parsed JSON, or text | The default — JSON with a safety net |
|
|
59
|
-
| `"json"` | Parsed JSON, or text | The body is known to be JSON |
|
|
60
|
-
| `"text"` | `string` | CSV, XML, plain text |
|
|
61
|
-
| `"blob"` | `Blob` | Saving or displaying a file |
|
|
62
|
-
| `"arrayBuffer"` | `ArrayBuffer` | Reading bytes directly |
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
// in any frontend module
|
|
66
|
-
const pdf = await api.get("/api/report", undefined, { responseType: "blob" });
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`"auto"` and `"json"` fall back to the raw text when the body will not parse, so a
|
|
70
|
-
misconfigured endpoint returning an HTML error page surfaces that page rather than
|
|
71
|
-
a parse exception.
|
|
72
|
-
|
|
73
|
-
A `204 No Content` response — or any response with `Content-Length: 0` — resolves
|
|
74
|
-
to `undefined` rather than throwing, so a `DELETE` needs no special handling:
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
await api.delete("/api/avatars/1"); // → undefined
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
Handing a downloaded blob to the browser takes one more step, since the client
|
|
81
|
-
returns the data rather than saving it:
|
|
82
|
-
|
|
83
|
-
```ts
|
|
84
|
-
const blob = await api.get("/api/report", undefined, { responseType: "blob" });
|
|
85
|
-
|
|
86
|
-
const url = URL.createObjectURL(blob);
|
|
87
|
-
const a = Object.assign(document.createElement("a"), { href: url, download: "report.pdf" });
|
|
88
|
-
a.click();
|
|
89
|
-
URL.revokeObjectURL(url);
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
## Reading response metadata
|
|
93
|
-
|
|
94
|
-
Binary endpoints often carry the interesting information in headers. A per-request
|
|
95
|
-
`meta` callback reads them without installing a global interceptor:
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
// in any frontend module
|
|
99
|
-
let total: string | null = null;
|
|
100
|
-
const users = await api.get("/api/users", undefined, {
|
|
101
|
-
meta: (m) => (total = m.headers.get("X-Total")),
|
|
102
|
-
});
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
This is the way to reach pagination totals, rate-limit counters, and `ETag` values
|
|
106
|
-
while still receiving the parsed body as the return value.
|
|
107
|
-
|
|
108
|
-
## Query serialization
|
|
109
|
-
|
|
110
|
-
Query objects serialize arrays and nested objects with bracket notation:
|
|
111
|
-
`{ ids: [1, 2], filter: { status: "open" } }` becomes
|
|
112
|
-
`?ids[]=1&ids[]=2&filter[status]=open`.
|
|
113
|
-
|
|
114
|
-
## Next steps
|
|
115
|
-
|
|
116
|
-
- [Client overview](/docs/client) — the guide's front page and the rest of the sections.
|
|
117
|
-
- [Making requests](/docs/client/requests) — the full request surface.
|
|
118
|
-
- [Error handling](/docs/client/errors) — what a failed transfer throws.
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Client References
|
|
3
|
-
description: Every ApiClient method, config key, and error type in one table.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# References
|
|
7
|
-
|
|
8
|
-
## `createApiClient<Routes>(config)`
|
|
9
|
-
|
|
10
|
-
Returns an `ApiClient<Routes>` bound to your route map. The `config` is an
|
|
11
|
-
`ApiClientConfig` (see the [Configuration](/docs/client#configuration) table for the common
|
|
12
|
-
fields, plus `onError`, `onResponse`, `onRequest`, `onUnauthorized`, `onForbidden`,
|
|
13
|
-
and `circuitBreaker`).
|
|
14
|
-
|
|
15
|
-
## ApiClient methods
|
|
16
|
-
|
|
17
|
-
| Method | Signature | Description |
|
|
18
|
-
| ---------- | -------------------------------------------------------------------- | ---------------------------------- |
|
|
19
|
-
| `get` | `get(path, params?, options?: GetOptions): Promise<Response>` | Typed GET; path params then query. |
|
|
20
|
-
| `post` | `post(path, body?, options?: MutationOptions): Promise<Response>` | Typed POST with body. |
|
|
21
|
-
| `put` | `put(path, body?, options?: MutationOptions): Promise<Response>` | Typed PUT with body. |
|
|
22
|
-
| `patch` | `patch(path, body?, options?: MutationOptions): Promise<Response>` | Typed PATCH with body. |
|
|
23
|
-
| `delete` | `delete(path, params?, options?: RequestOptions): Promise<Response>` | Typed DELETE; path params only. |
|
|
24
|
-
| `setToken` | `setToken(token: TokenSource \| null): void` | Update or clear the bearer token. |
|
|
25
|
-
|
|
26
|
-
## ApiClientError
|
|
27
|
-
|
|
28
|
-
| Member | Signature | Description |
|
|
29
|
-
| -------------- | ----------------------- | -------------------------------------- |
|
|
30
|
-
| `status` | `number` | HTTP status code. |
|
|
31
|
-
| `statusText` | `string` | HTTP status text. |
|
|
32
|
-
| `body` | `string` | Raw response text. |
|
|
33
|
-
| `headers` | `Headers \| undefined` | Response headers, when available. |
|
|
34
|
-
| `retryAfterMs` | `get(): number \| null` | Parsed `Retry-After` in ms, or `null`. |
|
|
35
|
-
|
|
36
|
-
## ValidationError extends ApiClientError
|
|
37
|
-
|
|
38
|
-
| Member | Signature | Description |
|
|
39
|
-
| ------------------- | ------------------------------------------- | ---------------------------------- |
|
|
40
|
-
| `errors` | `Record<string, string[]>` | Field → messages map. |
|
|
41
|
-
| `validationMessage` | `string` | Top-level `message` from the body. |
|
|
42
|
-
| `has` | `has(field: string): boolean` | Whether a field has any error. |
|
|
43
|
-
| `first` | `first(field: string): string \| undefined` | First message for a field. |
|
|
44
|
-
| `all` | `all(): Record<string, string[]>` | The full field-error map. |
|
|
45
|
-
| `fields` | `fields(): string[]` | Names of every failed field. |
|
|
46
|
-
|
|
47
|
-
## CircuitBreaker
|
|
48
|
-
|
|
49
|
-
| Member | Signature | Description |
|
|
50
|
-
| ---------- | ------------------------------------------- | --------------------------------------- |
|
|
51
|
-
| `call` | `call<T>(fn: () => Promise<T>): Promise<T>` | Run `fn` under the breaker. |
|
|
52
|
-
| `state` | `get(): CircuitState` | `'closed'`, `'open'`, or `'half-open'`. |
|
|
53
|
-
| `failures` | `get(): number` | Current consecutive failure count. |
|
|
54
|
-
| `reset` | `reset(): void` | Manually return to the closed state. |
|
|
55
|
-
|
|
56
|
-
## Next steps
|
|
57
|
-
|
|
58
|
-
- [Client overview](/docs/client) — the guide's front page and the rest of the sections.
|
package/docs/client/requests.md
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Client Requests
|
|
3
|
-
description: The typed route map, making requests, and shaping them with interceptors.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Route map
|
|
7
|
-
|
|
8
|
-
Define your API surface once. Keys are `'METHOD /path'` strings — path params are
|
|
9
|
-
`{braces}` style. All fields are optional.
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
// app/api/api-types.ts
|
|
13
|
-
export interface Routes {
|
|
14
|
-
"GET /api/users": {
|
|
15
|
-
query: { page?: number; perPage?: number; search?: string };
|
|
16
|
-
response: { data: UserResource[]; total: number };
|
|
17
|
-
};
|
|
18
|
-
"GET /api/users/{id}": {
|
|
19
|
-
params: { id: number };
|
|
20
|
-
response: UserResource;
|
|
21
|
-
};
|
|
22
|
-
"POST /api/users": {
|
|
23
|
-
body: { name: string; email: string; password: string };
|
|
24
|
-
response: UserResource;
|
|
25
|
-
};
|
|
26
|
-
"PUT /api/users/{id}": {
|
|
27
|
-
params: { id: number };
|
|
28
|
-
body: { name?: string; email?: string };
|
|
29
|
-
response: UserResource;
|
|
30
|
-
};
|
|
31
|
-
"DELETE /api/users/{id}": {
|
|
32
|
-
params: { id: number };
|
|
33
|
-
response: void;
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Making requests
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
// in any frontend module
|
|
42
|
-
// GET with path params
|
|
43
|
-
const user = await api.get("/api/users/{id}", { id: 42 });
|
|
44
|
-
// ^? UserResource — inferred from the route map
|
|
45
|
-
|
|
46
|
-
// GET with query string
|
|
47
|
-
const list = await api.get("/api/users", undefined, {
|
|
48
|
-
query: { page: 2, perPage: 25, search: "alice" },
|
|
49
|
-
});
|
|
50
|
-
// ^? { data: UserResource[]; total: number }
|
|
51
|
-
|
|
52
|
-
// POST with body
|
|
53
|
-
const created = await api.post("/api/users", {
|
|
54
|
-
name: "Alice",
|
|
55
|
-
email: "alice@example.com",
|
|
56
|
-
password: "hunter2",
|
|
57
|
-
});
|
|
58
|
-
// ^? UserResource
|
|
59
|
-
|
|
60
|
-
// PUT
|
|
61
|
-
await api.put("/api/users/{id}", { name: "Alice Smith" }, { params: { id: 42 } });
|
|
62
|
-
|
|
63
|
-
// PATCH
|
|
64
|
-
await api.patch("/api/users/{id}", { email: "new@example.com" }, { params: { id: 42 } });
|
|
65
|
-
|
|
66
|
-
// DELETE
|
|
67
|
-
await api.delete("/api/users/{id}", { id: 42 });
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
All methods accept an optional `options` argument for per-request headers and
|
|
71
|
-
extra `fetch` init fields (`signal`, `credentials`, etc.):
|
|
72
|
-
|
|
73
|
-
```ts
|
|
74
|
-
// in any frontend module
|
|
75
|
-
const ctrl = new AbortController();
|
|
76
|
-
|
|
77
|
-
await api.get("/api/users", undefined, {
|
|
78
|
-
headers: { "X-Trace-Id": requestId },
|
|
79
|
-
init: { signal: ctrl.signal },
|
|
80
|
-
});
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## Request interceptors
|
|
84
|
-
|
|
85
|
-
Run one or more async functions before every outgoing request. Each interceptor
|
|
86
|
-
receives the current `RequestConfig` and must return it (or a new one). Useful for
|
|
87
|
-
attaching authorization headers from a reactive store without coupling the store
|
|
88
|
-
to the client's constructor.
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
// app/api/client.ts
|
|
92
|
-
const api = createApiClient<Routes>({
|
|
93
|
-
baseUrl: "https://api.example.com",
|
|
94
|
-
|
|
95
|
-
// Single interceptor
|
|
96
|
-
onRequest: async (config) => ({
|
|
97
|
-
...config,
|
|
98
|
-
headers: {
|
|
99
|
-
...config.headers,
|
|
100
|
-
Authorization: `Bearer ${await tokenStore.get()}`,
|
|
101
|
-
},
|
|
102
|
-
}),
|
|
103
|
-
});
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
Multiple interceptors execute in declaration order:
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
|
-
// app/api/client.ts
|
|
110
|
-
const api = createApiClient<Routes>({
|
|
111
|
-
baseUrl: "https://api.example.com",
|
|
112
|
-
onRequest: [addAuthHeader, addRequestId, logOutgoing],
|
|
113
|
-
});
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
A symmetric `onResponse` runs after every successful (2xx) response — receiving a
|
|
117
|
-
`ResponseContext` of `{ status, headers, data, request }` — to unwrap envelopes or log.
|
|
118
|
-
|
|
119
|
-
### RequestConfig shape
|
|
120
|
-
|
|
121
|
-
| Field | Type | Description |
|
|
122
|
-
| --------- | ------------------------ | ------------------------------------------------------------ |
|
|
123
|
-
| `method` | `string` | HTTP verb — `'GET'`, `'POST'`, … |
|
|
124
|
-
| `url` | `string` | Full resolved URL (base + path + query string) |
|
|
125
|
-
| `headers` | `Record<string, string>` | Merged headers — add/override here |
|
|
126
|
-
| `body` | `BodyInit \| undefined` | Serialised body (JSON string, `FormData`, …), or `undefined` |
|
|
127
|
-
|
|
128
|
-
## Next steps
|
|
129
|
-
|
|
130
|
-
- [Client overview](/docs/client) — the guide's front page and the rest of the sections.
|
|
131
|
-
- [Reference](/docs/client/references) — the full API surface in one table.
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Client Resilience
|
|
3
|
-
description: Timeouts, retries, and the circuit breaker that spares a failing upstream.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Timeouts & retries
|
|
7
|
-
|
|
8
|
-
Set a default `timeout` (ms) and/or a `retry` policy on the client, and override either per request:
|
|
9
|
-
|
|
10
|
-
```ts
|
|
11
|
-
// app/api/client.ts
|
|
12
|
-
const api = createApiClient<Routes>({
|
|
13
|
-
timeout: 10_000,
|
|
14
|
-
retry: 2, // or { attempts, methods, statuses, backoff, respectRetryAfter }
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
await api.get("/api/users", undefined, { timeout: 2_000, retry: false });
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Retries apply to **idempotent** methods by default (GET/PUT/DELETE/HEAD/OPTIONS) and trigger on
|
|
21
|
-
network errors, request timeouts, and `408 / 425 / 429 / 5xx` responses, using exponential backoff
|
|
22
|
-
with jitter and honoring any `Retry-After` header. A timeout aborts via `AbortSignal`; pass your
|
|
23
|
-
own `signal` to cancel manually (it's combined with the timeout).
|
|
24
|
-
|
|
25
|
-
> **Warning** — A caller-cancelled request (`AbortError`) is never retried, but a request that
|
|
26
|
-
> times out (`TimeoutError`) is treated as transient and retried per your policy.
|
|
27
|
-
|
|
28
|
-
## Circuit breaker
|
|
29
|
-
|
|
30
|
-
Attach a circuit breaker to stop hammering a struggling upstream service and fail
|
|
31
|
-
fast instead. When the circuit is open, requests throw `CircuitBreakerOpenError`
|
|
32
|
-
immediately — no network call is made.
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
// app/api/client.ts
|
|
36
|
-
import { createApiClient } from "@zerotal/client";
|
|
37
|
-
|
|
38
|
-
// Option 1 — options object (creates a dedicated breaker)
|
|
39
|
-
const api = createApiClient<Routes>({
|
|
40
|
-
baseUrl: "https://api.example.com",
|
|
41
|
-
circuitBreaker: {
|
|
42
|
-
threshold: 5, // open after 5 consecutive failures
|
|
43
|
-
resetTimeout: 30_000, // attempt recovery after 30 s
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Option 2 — shared instance (multiple clients trip the same circuit)
|
|
48
|
-
import { CircuitBreaker } from "@zerotal/client";
|
|
49
|
-
|
|
50
|
-
const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 10_000 });
|
|
51
|
-
|
|
52
|
-
const usersApi = createApiClient<Routes>({ baseUrl: "…", circuitBreaker: breaker });
|
|
53
|
-
const postsApi = createApiClient<Routes>({ baseUrl: "…", circuitBreaker: breaker });
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### Which option should I use?
|
|
57
|
-
|
|
58
|
-
- **Options object** — one client talks to one upstream. Each client gets its own dedicated breaker.
|
|
59
|
-
- **Shared `CircuitBreaker` instance** — several clients hit the _same_ upstream and should trip together as a unit.
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
// in any frontend module
|
|
63
|
-
import { CircuitBreakerOpenError } from "@zerotal/client";
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
await api.get("/api/users");
|
|
67
|
-
} catch (err) {
|
|
68
|
-
if (err instanceof CircuitBreakerOpenError) {
|
|
69
|
-
// Return cached data, show degraded UI, etc.
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### Failure classification
|
|
75
|
-
|
|
76
|
-
By default:
|
|
77
|
-
|
|
78
|
-
- **5xx responses** → counted as failures (upstream is unhealthy)
|
|
79
|
-
- **4xx responses** → not counted (client mistakes, not upstream outages)
|
|
80
|
-
- **Network / DNS / timeout errors** → counted as failures
|
|
81
|
-
|
|
82
|
-
Override with a custom predicate:
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
// in any frontend module
|
|
86
|
-
new CircuitBreaker({
|
|
87
|
-
isFailure: (err) => {
|
|
88
|
-
if (err instanceof ApiClientError) return err.status >= 500;
|
|
89
|
-
return true; // any non-ApiClientError (network failure) counts
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Standalone CircuitBreaker
|
|
95
|
-
|
|
96
|
-
`CircuitBreaker` can wrap any async operation — not just HTTP requests.
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
// in any module
|
|
100
|
-
import { CircuitBreaker, CircuitBreakerOpenError } from "@zerotal/client";
|
|
101
|
-
|
|
102
|
-
const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 15_000 });
|
|
103
|
-
|
|
104
|
-
async function fetchPricing() {
|
|
105
|
-
return breaker.call(async () => {
|
|
106
|
-
const res = await fetch("https://pricing.internal/v1/rates");
|
|
107
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
108
|
-
return res.json();
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
### State machine
|
|
114
|
-
|
|
115
|
-
| State | Behaviour |
|
|
116
|
-
| ----------- | ------------------------------------------------------- |
|
|
117
|
-
| `closed` | Requests pass through; consecutive failures are counted |
|
|
118
|
-
| `open` | All calls immediately throw `CircuitBreakerOpenError` |
|
|
119
|
-
| `half-open` | One probe request is allowed through to test recovery |
|
|
120
|
-
|
|
121
|
-
Transitions:
|
|
122
|
-
|
|
123
|
-
- **closed → open** when failure count reaches `threshold`
|
|
124
|
-
- **open → half-open** after `resetTimeout` ms
|
|
125
|
-
- **half-open → closed** on a successful probe
|
|
126
|
-
- **half-open → open** on a failed probe (timer resets)
|
|
127
|
-
|
|
128
|
-
While in `half-open`, concurrent calls that arrive before the probe completes also
|
|
129
|
-
throw `CircuitBreakerOpenError` — only one probe goes through at a time.
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
// in any module
|
|
133
|
-
breaker.state; // 'closed' | 'open' | 'half-open'
|
|
134
|
-
breaker.failures; // current consecutive failure count
|
|
135
|
-
breaker.reset(); // manually reset to closed (e.g. after an admin action)
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## Next steps
|
|
139
|
-
|
|
140
|
-
- [Client overview](/docs/client) — the guide's front page and the rest of the sections.
|
|
141
|
-
- [Reference](/docs/client/references) — the full API surface in one table.
|
package/docs/client/testing.md
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Testing the Client
|
|
3
|
-
description: Stub the global fetch, cover the failure paths, and drive the circuit breaker.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Testing
|
|
7
|
-
|
|
8
|
-
Set your suite up once as described in [Testing](/docs/testing). `ApiClient`
|
|
9
|
-
calls the global `fetch`, so a test controls it by replacing that — there is no
|
|
10
|
-
separate fake to install.
|
|
11
|
-
|
|
12
|
-
```typescript
|
|
13
|
-
// tests/services/BillingClient.test.ts
|
|
14
|
-
import { test, expect, afterEach } from "bun:test";
|
|
15
|
-
import { ApiClient } from "@zerotal/client";
|
|
16
|
-
|
|
17
|
-
const realFetch = globalThis.fetch;
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
globalThis.fetch = realFetch;
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("maps a customer payload onto our shape", async () => {
|
|
23
|
-
globalThis.fetch = async () =>
|
|
24
|
-
new Response(JSON.stringify({ id: "cus_1", email: "jane@example.com" }), {
|
|
25
|
-
status: 200,
|
|
26
|
-
headers: { "Content-Type": "application/json" },
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const client = new ApiClient({ baseUrl: "https://api.example.com" });
|
|
30
|
-
const customer = await client.get("/customers/cus_1");
|
|
31
|
-
|
|
32
|
-
expect(customer.email).toBe("jane@example.com");
|
|
33
|
-
});
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
**Restore `fetch` in `afterEach`, not at the end of the test.** A test that
|
|
37
|
-
throws before its cleanup line leaves the stub installed, and every later test in
|
|
38
|
-
the file talks to it instead of the network — producing failures that point at
|
|
39
|
-
innocent code.
|
|
40
|
-
|
|
41
|
-
## Asserting on the request
|
|
42
|
-
|
|
43
|
-
Stubbing the response proves your code reads the answer correctly. It says nothing
|
|
44
|
-
about whether you asked the right question — a client that sends the wrong URL,
|
|
45
|
-
loses a query parameter, or drops the auth header passes every response-shaped test
|
|
46
|
-
and still fails in production. Capture what `fetch` received and assert on it:
|
|
47
|
-
|
|
48
|
-
```typescript
|
|
49
|
-
// tests/services/BillingClient.test.ts
|
|
50
|
-
test("sends the query and the auth header", async () => {
|
|
51
|
-
let url: string | undefined;
|
|
52
|
-
let init: RequestInit | undefined;
|
|
53
|
-
|
|
54
|
-
globalThis.fetch = async (u, i) => {
|
|
55
|
-
url = String(u);
|
|
56
|
-
init = i;
|
|
57
|
-
return new Response("[]", { status: 200 });
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const client = new ApiClient({
|
|
61
|
-
baseUrl: "https://api.example.com",
|
|
62
|
-
token: "tok_123",
|
|
63
|
-
});
|
|
64
|
-
await client.get("/customers", { status: "active" });
|
|
65
|
-
|
|
66
|
-
expect(url).toBe("https://api.example.com/customers?status=active");
|
|
67
|
-
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer tok_123");
|
|
68
|
-
});
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Wrap the headers in `new Headers(...)` before reading them. Casing is normalised
|
|
72
|
-
that way, so the assertion holds whether the value arrived as a plain object or as
|
|
73
|
-
a `Headers` instance from a per-request override.
|
|
74
|
-
|
|
75
|
-
Uploads deserve one such test each. Asserting that `init.body` is the `FormData`
|
|
76
|
-
you passed — rather than a JSON string — is what pins down that the body was not
|
|
77
|
-
re-encoded on its way out.
|
|
78
|
-
|
|
79
|
-
**Test the failure paths, because they are the ones production hits.** A 500, a
|
|
80
|
-
timeout, and a malformed body all reach your code differently:
|
|
81
|
-
|
|
82
|
-
```typescript
|
|
83
|
-
// tests/services/BillingClient.test.ts
|
|
84
|
-
test("a 500 surfaces as ApiClientError", async () => {
|
|
85
|
-
globalThis.fetch = async () => new Response("upstream boom", { status: 500 });
|
|
86
|
-
|
|
87
|
-
await expect(client.get("/customers/cus_1")).rejects.toBeInstanceOf(ApiClientError);
|
|
88
|
-
});
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
A network failure is a different case again, and the stub models it by rejecting
|
|
92
|
-
instead of resolving — which is how you reach the branch that never receives a
|
|
93
|
-
status at all:
|
|
94
|
-
|
|
95
|
-
```typescript
|
|
96
|
-
globalThis.fetch = async () => {
|
|
97
|
-
throw new TypeError("Failed to fetch");
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
await expect(client.get("/customers")).rejects.not.toBeInstanceOf(ApiClientError);
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
## Retries and the circuit breaker
|
|
104
|
-
|
|
105
|
-
A retry policy is invisible from the outside — the caller sees one resolved promise
|
|
106
|
-
whether it took one attempt or four. Counting calls is what makes the behaviour
|
|
107
|
-
observable:
|
|
108
|
-
|
|
109
|
-
```typescript
|
|
110
|
-
// tests/services/BillingClient.test.ts
|
|
111
|
-
let calls = 0;
|
|
112
|
-
globalThis.fetch = async () => {
|
|
113
|
-
calls++;
|
|
114
|
-
return calls < 3 ? new Response("", { status: 503 }) : new Response("{}", { status: 200 });
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
await client.get("/customers");
|
|
118
|
-
expect(calls).toBe(3); // two failures, then success
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
**A circuit breaker is a state machine**, so drive it with repeated failures and
|
|
122
|
-
assert it opens — then that it refuses without calling `fetch` at all:
|
|
123
|
-
|
|
124
|
-
```typescript
|
|
125
|
-
// tests/services/BillingClient.test.ts
|
|
126
|
-
let calls = 0;
|
|
127
|
-
globalThis.fetch = async () => {
|
|
128
|
-
calls++;
|
|
129
|
-
return new Response("", { status: 500 });
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
for (let i = 0; i < threshold; i++) await client.get("/x").catch(() => {});
|
|
133
|
-
const before = calls;
|
|
134
|
-
|
|
135
|
-
await expect(client.get("/x")).rejects.toBeInstanceOf(CircuitBreakerOpenError);
|
|
136
|
-
expect(calls).toBe(before); // the open circuit short-circuits, no request made
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
That last assertion is the point of a breaker — without it you have tested that
|
|
140
|
-
an error is thrown, not that the upstream was spared.
|
|
141
|
-
|
|
142
|
-
## Next steps
|
|
143
|
-
|
|
144
|
-
- [Client overview](/docs/client) — the guide's front page and the rest of the sections.
|
|
145
|
-
- [Resilience](/docs/client/resilience) — the retry and breaker policies under test.
|
|
146
|
-
- [Error handling](/docs/client/errors) — the errors these tests assert on.
|