@emesoft_company/playwright-core 2.1.0
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/README.md +62 -0
- package/dist/api/index.cjs +248 -0
- package/dist/api/index.cjs.map +1 -0
- package/dist/api/index.d.cts +12 -0
- package/dist/api/index.d.ts +12 -0
- package/dist/api/index.js +245 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api-client-C_StjpPX.d.ts +58 -0
- package/dist/api-client-CpXo_NOU.d.cts +58 -0
- package/dist/auth/index.cjs +310 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +98 -0
- package/dist/auth/index.d.ts +98 -0
- package/dist/auth/index.js +304 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/config/index.cjs +268 -0
- package/dist/config/index.cjs.map +1 -0
- package/dist/config/index.d.cts +105 -0
- package/dist/config/index.d.ts +105 -0
- package/dist/config/index.js +254 -0
- package/dist/config/index.js.map +1 -0
- package/dist/data/index.cjs +59 -0
- package/dist/data/index.cjs.map +1 -0
- package/dist/data/index.d.cts +25 -0
- package/dist/data/index.d.ts +25 -0
- package/dist/data/index.js +50 -0
- package/dist/data/index.js.map +1 -0
- package/dist/di/index.cjs +47 -0
- package/dist/di/index.cjs.map +1 -0
- package/dist/di/index.d.cts +19 -0
- package/dist/di/index.d.ts +19 -0
- package/dist/di/index.js +45 -0
- package/dist/di/index.js.map +1 -0
- package/dist/errors/index.cjs +90 -0
- package/dist/errors/index.cjs.map +1 -0
- package/dist/errors/index.d.cts +38 -0
- package/dist/errors/index.d.ts +38 -0
- package/dist/errors/index.js +83 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/expect/index.cjs +36 -0
- package/dist/expect/index.cjs.map +1 -0
- package/dist/expect/index.d.cts +30 -0
- package/dist/expect/index.d.ts +30 -0
- package/dist/expect/index.js +34 -0
- package/dist/expect/index.js.map +1 -0
- package/dist/fixtures/index.cjs +660 -0
- package/dist/fixtures/index.cjs.map +1 -0
- package/dist/fixtures/index.d.cts +68 -0
- package/dist/fixtures/index.d.ts +68 -0
- package/dist/fixtures/index.js +651 -0
- package/dist/fixtures/index.js.map +1 -0
- package/dist/index.cjs +1322 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1248 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/index.cjs +32 -0
- package/dist/logging/index.cjs.map +1 -0
- package/dist/logging/index.d.cts +18 -0
- package/dist/logging/index.d.ts +18 -0
- package/dist/logging/index.js +25 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/reporting/index.cjs +59 -0
- package/dist/reporting/index.cjs.map +1 -0
- package/dist/reporting/index.d.cts +63 -0
- package/dist/reporting/index.d.ts +63 -0
- package/dist/reporting/index.js +52 -0
- package/dist/reporting/index.js.map +1 -0
- package/dist/retry-XqnSC8YL.d.cts +27 -0
- package/dist/retry-XqnSC8YL.d.ts +27 -0
- package/dist/schema-y5w2hbT-.d.cts +292 -0
- package/dist/schema-y5w2hbT-.d.ts +292 -0
- package/dist/types-CbWnSYEK.d.cts +5 -0
- package/dist/types-CbWnSYEK.d.ts +5 -0
- package/dist/types-CopwwpSW.d.cts +8 -0
- package/dist/types-CopwwpSW.d.ts +8 -0
- package/dist/types-hInbkPXf.d.cts +59 -0
- package/dist/types-hInbkPXf.d.ts +59 -0
- package/dist/ui/index.cjs +366 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +190 -0
- package/dist/ui/index.d.ts +190 -0
- package/dist/ui/index.js +348 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/utils/index.cjs +72 -0
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.d.cts +27 -0
- package/dist/utils/index.d.ts +27 -0
- package/dist/utils/index.js +66 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +133 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @emesoft_company/playwright-core
|
|
2
|
+
|
|
3
|
+
Shared, production-grade Playwright automation core consumed by independent test project
|
|
4
|
+
repositories. Provides fixtures, config loading, API client abstraction, auth helpers,
|
|
5
|
+
generic UI base components, structured logging, retry/error handling, and optional Allure
|
|
6
|
+
reporting integration.
|
|
7
|
+
|
|
8
|
+
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how core and consuming projects
|
|
9
|
+
interact, [`docs/EXTENDING.md`](docs/EXTENDING.md) for override patterns,
|
|
10
|
+
[`docs/PUBLISHING.md`](docs/PUBLISHING.md) for versioning/publishing strategy, and
|
|
11
|
+
[`docs/NEW_PROJECT.md`](docs/NEW_PROJECT.md) for scaffolding a new project that consumes core
|
|
12
|
+
(`scripts/scaffold-e2e-project.sh` + `templates/e2e-project/`).
|
|
13
|
+
|
|
14
|
+
## Install (consuming projects)
|
|
15
|
+
|
|
16
|
+
No private registry yet — install directly from this repo, pinned to a tag:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add "@emesoft_company/playwright-core@github:emesoft/playwright-core#v2.1.0"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For local development across repos in the same workspace, a project can point at the local
|
|
23
|
+
folder instead via a `file:` dependency (see `docs/PUBLISHING.md`).
|
|
24
|
+
|
|
25
|
+
## Develop
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm install
|
|
29
|
+
pnpm build # tsup -> dist/ (ESM + CJS + .d.ts)
|
|
30
|
+
pnpm test # vitest unit tests for loader/retry/etc.
|
|
31
|
+
pnpm typecheck
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Package layout
|
|
35
|
+
|
|
36
|
+
| Subpath | Purpose |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `@emesoft_company/playwright-core` | Barrel export of the full public API |
|
|
39
|
+
| `@emesoft_company/playwright-core/config` | Multi-environment config loader + zod schema |
|
|
40
|
+
| `@emesoft_company/playwright-core/fixtures` | Base Playwright fixtures (logger, apiClient, auth, retry) |
|
|
41
|
+
| `@emesoft_company/playwright-core/api` | API client abstraction over `APIRequestContext` |
|
|
42
|
+
| `@emesoft_company/playwright-core/auth` | Token provider / JWT auth helpers |
|
|
43
|
+
| `@emesoft_company/playwright-core/ui` | Generic `BasePage` / `BaseComponent` (no selectors, no business logic) |
|
|
44
|
+
| `@emesoft_company/playwright-core/logging` | Structured logger |
|
|
45
|
+
| `@emesoft_company/playwright-core/errors` | `AppError` classes + `withRetry` helper |
|
|
46
|
+
| `@emesoft_company/playwright-core/reporting` | Optional Allure reporter wiring |
|
|
47
|
+
| `@emesoft_company/playwright-core/di` | Lightweight service container used by fixtures |
|
|
48
|
+
| `@emesoft_company/playwright-core/utils` | `sleep`, `waitForCondition` (generic polling, outside UI locators), `deepMerge` |
|
|
49
|
+
| `@emesoft_company/playwright-core/data` | Unique/random value generators (`uniqueId`, `uniqueEmail`, `isoDate`, …) |
|
|
50
|
+
| `@emesoft_company/playwright-core/expect` | `expect` with `toHaveStatus` / `toMatchSchema` (also re-exported from `/fixtures`) |
|
|
51
|
+
|
|
52
|
+
## What a project gets for free
|
|
53
|
+
|
|
54
|
+
- **`defineE2EConfig()`** — the entire `playwright.config.ts` in three lines
|
|
55
|
+
- **`TableComponent`** — cells by column name, whole-table `records()`, row filtering
|
|
56
|
+
- **`BasePage` / `BaseComponent`** — safe click/fill/select/upload, scoped lookups, waits
|
|
57
|
+
- **`ApiClient`** — bearer auth, retry on 5xx/429, zod-validated responses, assertable error statuses
|
|
58
|
+
- **Auth** — one-time login (single or per role) with reusable storage state
|
|
59
|
+
- **Diagnostics** — console/page errors and failed requests attached to the report automatically
|
|
60
|
+
- **`cleanup`** — LIFO teardown for data a test created
|
|
61
|
+
|
|
62
|
+
Upgrading from 1.x? See [`docs/MIGRATION-v2.md`](docs/MIGRATION-v2.md).
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors/app-error.ts
|
|
4
|
+
var AppError = class extends Error {
|
|
5
|
+
code;
|
|
6
|
+
context;
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, { cause: options.cause });
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
this.code = options.code;
|
|
11
|
+
this.context = options.context;
|
|
12
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var ApiError = class extends AppError {
|
|
16
|
+
status;
|
|
17
|
+
method;
|
|
18
|
+
url;
|
|
19
|
+
constructor(message, options) {
|
|
20
|
+
super(message, options);
|
|
21
|
+
this.status = options.status;
|
|
22
|
+
this.method = options.method;
|
|
23
|
+
this.url = options.url;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/auth/token-provider.ts
|
|
28
|
+
var NullTokenProvider = class {
|
|
29
|
+
async getToken() {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
invalidate() {
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/utils/sleep.ts
|
|
37
|
+
function sleep(ms) {
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/errors/retry.ts
|
|
42
|
+
function backoffDelay(attempt, minDelayMs, maxDelayMs, factor) {
|
|
43
|
+
const exponential = minDelayMs * factor ** (attempt - 1);
|
|
44
|
+
const jitter = Math.random() * minDelayMs;
|
|
45
|
+
return Math.min(exponential + jitter, maxDelayMs);
|
|
46
|
+
}
|
|
47
|
+
async function withRetry(fn, options = {}) {
|
|
48
|
+
const {
|
|
49
|
+
retries = 2,
|
|
50
|
+
minDelayMs = 200,
|
|
51
|
+
maxDelayMs = 5e3,
|
|
52
|
+
factor = 2,
|
|
53
|
+
retryOn = () => true,
|
|
54
|
+
onRetry,
|
|
55
|
+
onGiveUp
|
|
56
|
+
} = options;
|
|
57
|
+
const maxAttempts = retries + 1;
|
|
58
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
return await fn();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const isLastAttempt = attempt >= maxAttempts;
|
|
63
|
+
if (isLastAttempt || !retryOn(error)) {
|
|
64
|
+
onGiveUp?.(error, attempt);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
onRetry?.(error, attempt);
|
|
68
|
+
await sleep(backoffDelay(attempt, minDelayMs, maxDelayMs, factor));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new Error("withRetry: unreachable state");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/api/interceptors.ts
|
|
75
|
+
function isRetryableStatus(status) {
|
|
76
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/api/api-client.ts
|
|
80
|
+
var TransientHttpError = class extends Error {
|
|
81
|
+
constructor(status) {
|
|
82
|
+
super(`Transient HTTP status ${status}`);
|
|
83
|
+
this.status = status;
|
|
84
|
+
}
|
|
85
|
+
status;
|
|
86
|
+
};
|
|
87
|
+
var ApiClient = class {
|
|
88
|
+
request;
|
|
89
|
+
baseUrl;
|
|
90
|
+
tokenProvider;
|
|
91
|
+
logger;
|
|
92
|
+
retryOptions;
|
|
93
|
+
defaultHeaders;
|
|
94
|
+
interceptors;
|
|
95
|
+
constructor(options) {
|
|
96
|
+
this.request = options.request;
|
|
97
|
+
this.baseUrl = options.baseUrl;
|
|
98
|
+
this.tokenProvider = options.tokenProvider ?? new NullTokenProvider();
|
|
99
|
+
this.logger = options.logger;
|
|
100
|
+
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
101
|
+
this.interceptors = options.interceptors ?? {};
|
|
102
|
+
this.retryOptions = { ...options.retry };
|
|
103
|
+
}
|
|
104
|
+
get(path, options) {
|
|
105
|
+
return this.requestBody("GET", path, options);
|
|
106
|
+
}
|
|
107
|
+
post(path, options) {
|
|
108
|
+
return this.requestBody("POST", path, options);
|
|
109
|
+
}
|
|
110
|
+
put(path, options) {
|
|
111
|
+
return this.requestBody("PUT", path, options);
|
|
112
|
+
}
|
|
113
|
+
patch(path, options) {
|
|
114
|
+
return this.requestBody("PATCH", path, options);
|
|
115
|
+
}
|
|
116
|
+
delete(path, options) {
|
|
117
|
+
return this.requestBody("DELETE", path, options);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Full response including status and headers. Retries transient statuses, then returns
|
|
121
|
+
* whatever came back — a 404 or 500 is a normal return value here, not an exception.
|
|
122
|
+
* Network/timeout failures still throw.
|
|
123
|
+
*/
|
|
124
|
+
async send(method, path, options = {}) {
|
|
125
|
+
const url = this.resolveUrl(path, options.params);
|
|
126
|
+
let lastResponse;
|
|
127
|
+
try {
|
|
128
|
+
await withRetry(
|
|
129
|
+
async () => {
|
|
130
|
+
lastResponse = await this.fetchOnce(method, url, options);
|
|
131
|
+
if (isRetryableStatus(lastResponse.status) && !this.isExpectedStatus(lastResponse.status, options)) {
|
|
132
|
+
throw new TransientHttpError(lastResponse.status);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
...this.retryOptions,
|
|
137
|
+
retryOn: (error) => error instanceof TransientHttpError,
|
|
138
|
+
onRetry: (error, attempt) => this.logger?.warn({ method, url, attempt, err: error }, "retrying API request")
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (!(error instanceof TransientHttpError)) throw error;
|
|
143
|
+
}
|
|
144
|
+
if (!lastResponse) {
|
|
145
|
+
throw new AppError(`API request produced no response: ${method} ${url}`, {
|
|
146
|
+
code: "API_NO_RESPONSE",
|
|
147
|
+
context: { method, url }
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return lastResponse;
|
|
151
|
+
}
|
|
152
|
+
async requestBody(method, path, options = {}) {
|
|
153
|
+
const response = await this.send(method, path, options);
|
|
154
|
+
if (!this.isExpectedStatus(response.status, options)) {
|
|
155
|
+
if (response.status === 401) this.tokenProvider.invalidate();
|
|
156
|
+
this.logger?.error(
|
|
157
|
+
{ status: response.status, method, url: response.url, body: response.data },
|
|
158
|
+
"API request failed"
|
|
159
|
+
);
|
|
160
|
+
throw new ApiError(
|
|
161
|
+
`API request failed: ${method} ${response.url} -> ${response.status}` + (options.expectStatus === void 0 ? "" : ` (expected ${String(options.expectStatus)})`),
|
|
162
|
+
{
|
|
163
|
+
code: "API_REQUEST_FAILED",
|
|
164
|
+
status: response.status,
|
|
165
|
+
method,
|
|
166
|
+
url: response.url,
|
|
167
|
+
context: { body: response.data }
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return response.data;
|
|
172
|
+
}
|
|
173
|
+
isExpectedStatus(status, options) {
|
|
174
|
+
if (options.expectStatus === void 0) return status >= 200 && status < 300;
|
|
175
|
+
const expected = Array.isArray(options.expectStatus) ? options.expectStatus : [options.expectStatus];
|
|
176
|
+
return expected.includes(status);
|
|
177
|
+
}
|
|
178
|
+
async fetchOnce(method, url, options) {
|
|
179
|
+
const headers = await this.buildHeaders(method, url, options);
|
|
180
|
+
const response = await this.request.fetch(url, {
|
|
181
|
+
method,
|
|
182
|
+
headers,
|
|
183
|
+
data: options.data,
|
|
184
|
+
form: options.form,
|
|
185
|
+
multipart: options.multipart,
|
|
186
|
+
timeout: options.timeoutMs
|
|
187
|
+
});
|
|
188
|
+
await this.interceptors.onResponse?.({ method, url, status: response.status() });
|
|
189
|
+
return {
|
|
190
|
+
status: response.status(),
|
|
191
|
+
ok: response.ok(),
|
|
192
|
+
headers: response.headers(),
|
|
193
|
+
data: await this.parseBody(response, options),
|
|
194
|
+
url,
|
|
195
|
+
method
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
resolveUrl(path, params) {
|
|
199
|
+
const base = this.baseUrl ? new URL(path, this.baseUrl).toString() : path;
|
|
200
|
+
if (!params) return base;
|
|
201
|
+
const url = new URL(base);
|
|
202
|
+
for (const [key, value] of Object.entries(params)) {
|
|
203
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
204
|
+
}
|
|
205
|
+
return url.toString();
|
|
206
|
+
}
|
|
207
|
+
async buildHeaders(method, url, options) {
|
|
208
|
+
let headers = { ...this.defaultHeaders, ...options.headers };
|
|
209
|
+
if (!options.skipAuth) {
|
|
210
|
+
const token = await this.tokenProvider.getToken();
|
|
211
|
+
if (token) headers = { ...headers, Authorization: `Bearer ${token}` };
|
|
212
|
+
}
|
|
213
|
+
const overridden = await this.interceptors.onRequest?.({ method, url, headers });
|
|
214
|
+
return overridden ?? headers;
|
|
215
|
+
}
|
|
216
|
+
async parseBody(response, options) {
|
|
217
|
+
const raw = await this.readBody(response);
|
|
218
|
+
if (!options.schema) return raw;
|
|
219
|
+
const result = options.schema.safeParse(raw);
|
|
220
|
+
if (!result.success) {
|
|
221
|
+
throw new ApiError(`API response did not match the expected schema: ${response.url()}`, {
|
|
222
|
+
code: "API_SCHEMA_MISMATCH",
|
|
223
|
+
status: response.status(),
|
|
224
|
+
url: response.url(),
|
|
225
|
+
context: { issues: result.error.issues, body: raw }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return result.data;
|
|
229
|
+
}
|
|
230
|
+
async readBody(response) {
|
|
231
|
+
const contentType = response.headers()["content-type"] ?? "";
|
|
232
|
+
if (contentType.includes("application/json")) {
|
|
233
|
+
const text = await response.text();
|
|
234
|
+
if (text.trim() === "") return void 0;
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(text);
|
|
237
|
+
} catch {
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return response.text();
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
exports.ApiClient = ApiClient;
|
|
246
|
+
exports.isRetryableStatus = isRetryableStatus;
|
|
247
|
+
//# sourceMappingURL=index.cjs.map
|
|
248
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/errors/app-error.ts","../../src/auth/token-provider.ts","../../src/utils/sleep.ts","../../src/errors/retry.ts","../../src/api/interceptors.ts","../../src/api/api-client.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["export interface AppErrorOptions {\n code: string;\n cause?: unknown;\n context?: Record<string, unknown>;\n}\n\n/**\n * Base error class for the framework. Every error thrown by core carries a\n * machine-readable `code` and optional `context`, so callers can branch on\n * `error.code` instead of parsing messages.\n */\nexport class AppError extends Error {\n readonly code: string;\n readonly context?: Record<string, unknown>;\n\n constructor(message: string, options: AppErrorOptions) {\n super(message, { cause: options.cause });\n this.name = new.target.name;\n this.code = options.code;\n this.context = options.context;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\nexport class ConfigValidationError extends AppError {\n constructor(message: string, issues: unknown, context?: Record<string, unknown>) {\n super(message, { code: \"CONFIG_VALIDATION_ERROR\", context: { issues, ...context } });\n }\n}\n\nexport class ApiError extends AppError {\n readonly status?: number;\n readonly method?: string;\n readonly url?: string;\n\n constructor(\n message: string,\n options: AppErrorOptions & { status?: number; method?: string; url?: string },\n ) {\n super(message, options);\n this.status = options.status;\n this.method = options.method;\n this.url = options.url;\n }\n}\n\nexport class AuthError extends AppError {\n constructor(message: string, options: Omit<AppErrorOptions, \"code\"> = {}) {\n super(message, { code: \"AUTH_ERROR\", ...options });\n }\n}\n\nexport class ConfigMissingError extends AppError {\n constructor(name: string) {\n super(`Required environment variable \"${name}\" is not set`, {\n code: \"ENV_VAR_MISSING\",\n context: { name },\n });\n }\n}\n","import { AuthError } from \"../errors/app-error.js\";\nimport type { Logger } from \"../logging/logger.js\";\nimport type { TokenProvider } from \"./types.js\";\n\n/** Token provider that always returns a fixed token. Useful for tests/local dev with a pre-issued token. */\nexport class StaticTokenProvider implements TokenProvider {\n constructor(private readonly token: string) {}\n\n async getToken(): Promise<string> {\n return this.token;\n }\n\n invalidate(): void {\n // no-op: nothing to invalidate for a static token\n }\n}\n\n/** No-op provider for endpoints that don't require authentication. */\nexport class NullTokenProvider implements TokenProvider {\n async getToken(): Promise<string> {\n return \"\";\n }\n\n invalidate(): void {\n // no-op\n }\n}\n\nexport interface JwtTokenProviderOptions {\n tokenUrl: string;\n clientId?: string;\n clientSecret?: string;\n username?: string;\n password?: string;\n /** Seconds subtracted from the token's `expires_in` to refresh proactively. Default: 30. */\n refreshSkewSeconds?: number;\n logger?: Logger;\n fetchImpl?: typeof fetch;\n}\n\ninterface CachedToken {\n token: string;\n expiresAt: number;\n}\n\n/**\n * JWT/OAuth2 token provider supporting client-credentials or password grants.\n * Caches the token in-memory and transparently refreshes it before expiry.\n */\nexport class JwtTokenProvider implements TokenProvider {\n private cached?: CachedToken;\n private inFlight?: Promise<string>;\n\n constructor(private readonly options: JwtTokenProviderOptions) {}\n\n async getToken(): Promise<string> {\n if (this.cached && this.cached.expiresAt > Date.now()) {\n return this.cached.token;\n }\n if (!this.inFlight) {\n this.inFlight = this.fetchToken().finally(() => {\n this.inFlight = undefined;\n });\n }\n return this.inFlight;\n }\n\n invalidate(): void {\n this.cached = undefined;\n }\n\n private async fetchToken(): Promise<string> {\n const { tokenUrl, clientId, clientSecret, username, password, refreshSkewSeconds = 30, logger, fetchImpl = fetch } = this.options;\n\n const body = new URLSearchParams();\n if (username && password) {\n body.set(\"grant_type\", \"password\");\n body.set(\"username\", username);\n body.set(\"password\", password);\n } else {\n body.set(\"grant_type\", \"client_credentials\");\n }\n if (clientId) body.set(\"client_id\", clientId);\n if (clientSecret) body.set(\"client_secret\", clientSecret);\n\n logger?.debug({ tokenUrl }, \"fetching auth token\");\n\n let response: Response;\n try {\n response = await fetchImpl(tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body,\n });\n } catch (cause) {\n throw new AuthError(`Failed to reach token endpoint ${tokenUrl}`, { cause });\n }\n\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new AuthError(`Token endpoint returned ${response.status}: ${text}`, {\n context: { status: response.status, tokenUrl },\n });\n }\n\n const data = (await response.json()) as { access_token?: string; expires_in?: number };\n if (!data.access_token) {\n throw new AuthError(\"Token endpoint response missing access_token\", { context: { tokenUrl } });\n }\n\n const expiresInSeconds = data.expires_in ?? 300;\n this.cached = {\n token: data.access_token,\n expiresAt: Date.now() + Math.max(expiresInSeconds - refreshSkewSeconds, 0) * 1000,\n };\n return this.cached.token;\n }\n}\n","export function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","import { sleep } from \"../utils/sleep.js\";\n\nexport interface RetryOptions {\n /** Number of retry attempts after the initial try. Default: 2. */\n retries?: number;\n minDelayMs?: number;\n maxDelayMs?: number;\n /** Exponential backoff factor. Default: 2. */\n factor?: number;\n /** Return true to retry this error, false to fail fast. Default: retry everything. */\n retryOn?: (error: unknown) => boolean;\n /** Called before each retry (not before the first attempt). */\n onRetry?: (error: unknown, attempt: number) => void;\n /** Called once when giving up, with the total number of attempts made. */\n onGiveUp?: (error: unknown, attempts: number) => void;\n}\n\nfunction backoffDelay(attempt: number, minDelayMs: number, maxDelayMs: number, factor: number): number {\n const exponential = minDelayMs * factor ** (attempt - 1);\n const jitter = Math.random() * minDelayMs;\n return Math.min(exponential + jitter, maxDelayMs);\n}\n\n/**\n * Runs `fn` with exponential-backoff retry. Used for both flaky API calls and\n * flaky UI actions so the retry policy stays consistent across the framework.\n *\n * Always rethrows the ORIGINAL error — whether it failed fast (`retryOn` returned\n * false) or exhausted its retries. That's what makes `catch (e) { e instanceof ApiError }`\n * and `expect(...).rejects.toThrow(ApiError)` work in tests; wrapping the error in a\n * retry-specific type would hide the only thing the caller actually wants to assert on.\n * Use `onGiveUp` if you need the attempt count.\n */\nexport async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {\n const {\n retries = 2,\n minDelayMs = 200,\n maxDelayMs = 5_000,\n factor = 2,\n retryOn = () => true,\n onRetry,\n onGiveUp,\n } = options;\n\n const maxAttempts = retries + 1;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await fn();\n } catch (error) {\n const isLastAttempt = attempt >= maxAttempts;\n if (isLastAttempt || !retryOn(error)) {\n onGiveUp?.(error, attempt);\n throw error;\n }\n onRetry?.(error, attempt);\n await sleep(backoffDelay(attempt, minDelayMs, maxDelayMs, factor));\n }\n }\n\n // Unreachable: the loop either returns or throws.\n throw new Error(\"withRetry: unreachable state\");\n}\n","/** 429 (rate-limited) and any 5xx are considered transient and safe to retry. */\nexport function isRetryableStatus(status: number): boolean {\n return status === 429 || (status >= 500 && status <= 599);\n}\n","import type { APIRequestContext, APIResponse } from \"@playwright/test\";\nimport { NullTokenProvider } from \"../auth/token-provider.js\";\nimport type { TokenProvider } from \"../auth/types.js\";\nimport { ApiError, AppError } from \"../errors/app-error.js\";\nimport { withRetry, type RetryOptions } from \"../errors/retry.js\";\nimport type { Logger } from \"../logging/logger.js\";\nimport { isRetryableStatus } from \"./interceptors.js\";\nimport type { ApiClientInterceptors, ApiRequestOptions, ApiResponse } from \"./types.js\";\n\nexport interface ApiClientOptions {\n /** Playwright's `APIRequestContext` (from `request` fixture or `context.request`). */\n request: APIRequestContext;\n baseUrl?: string;\n tokenProvider?: TokenProvider;\n logger?: Logger;\n retry?: Partial<RetryOptions>;\n defaultHeaders?: Record<string, string>;\n interceptors?: ApiClientInterceptors;\n}\n\n/** Internal signal used to drive retry backoff; never escapes `send()`. */\nclass TransientHttpError extends Error {\n constructor(readonly status: number) {\n super(`Transient HTTP status ${status}`);\n }\n}\n\n/**\n * Thin, project-agnostic abstraction over Playwright's `APIRequestContext`:\n * base URL resolution, bearer-token injection, structured error mapping, and\n * retry-on-5xx/429. Projects extend this for domain-specific API clients\n * instead of calling `request.fetch` directly in tests.\n *\n * Two levels of API, on purpose:\n * - `get`/`post`/... return the body and THROW `ApiError` on an unexpected status — the right\n * default for arranging test data, where a failed call should stop the test loudly.\n * - `send()` returns the full `ApiResponse` (status, headers, body) and never throws for an\n * HTTP status — for tests that assert on error responses themselves.\n */\nexport class ApiClient {\n private readonly request: APIRequestContext;\n private readonly baseUrl?: string;\n private readonly tokenProvider: TokenProvider;\n private readonly logger?: Logger;\n private readonly retryOptions: RetryOptions;\n private readonly defaultHeaders: Record<string, string>;\n private readonly interceptors: ApiClientInterceptors;\n\n constructor(options: ApiClientOptions) {\n this.request = options.request;\n this.baseUrl = options.baseUrl;\n this.tokenProvider = options.tokenProvider ?? new NullTokenProvider();\n this.logger = options.logger;\n this.defaultHeaders = options.defaultHeaders ?? {};\n this.interceptors = options.interceptors ?? {};\n this.retryOptions = { ...options.retry };\n }\n\n get<T = unknown>(path: string, options?: ApiRequestOptions<T>): Promise<T> {\n return this.requestBody<T>(\"GET\", path, options);\n }\n\n post<T = unknown>(path: string, options?: ApiRequestOptions<T>): Promise<T> {\n return this.requestBody<T>(\"POST\", path, options);\n }\n\n put<T = unknown>(path: string, options?: ApiRequestOptions<T>): Promise<T> {\n return this.requestBody<T>(\"PUT\", path, options);\n }\n\n patch<T = unknown>(path: string, options?: ApiRequestOptions<T>): Promise<T> {\n return this.requestBody<T>(\"PATCH\", path, options);\n }\n\n delete<T = unknown>(path: string, options?: ApiRequestOptions<T>): Promise<T> {\n return this.requestBody<T>(\"DELETE\", path, options);\n }\n\n /**\n * Full response including status and headers. Retries transient statuses, then returns\n * whatever came back — a 404 or 500 is a normal return value here, not an exception.\n * Network/timeout failures still throw.\n */\n async send<T = unknown>(\n method: string,\n path: string,\n options: ApiRequestOptions<T> = {},\n ): Promise<ApiResponse<T>> {\n const url = this.resolveUrl(path, options.params);\n let lastResponse: ApiResponse<T> | undefined;\n\n try {\n await withRetry(\n async () => {\n lastResponse = await this.fetchOnce<T>(method, url, options);\n if (isRetryableStatus(lastResponse.status) && !this.isExpectedStatus(lastResponse.status, options)) {\n throw new TransientHttpError(lastResponse.status);\n }\n },\n {\n ...this.retryOptions,\n retryOn: (error) => error instanceof TransientHttpError,\n onRetry: (error, attempt) =>\n this.logger?.warn({ method, url, attempt, err: error }, \"retrying API request\"),\n },\n );\n } catch (error) {\n // A transient status that never recovered is still a response the caller may want to\n // inspect; anything else (DNS, TLS, timeout) is a real failure and must propagate.\n if (!(error instanceof TransientHttpError)) throw error;\n }\n\n if (!lastResponse) {\n throw new AppError(`API request produced no response: ${method} ${url}`, {\n code: \"API_NO_RESPONSE\",\n context: { method, url },\n });\n }\n return lastResponse;\n }\n\n private async requestBody<T>(\n method: string,\n path: string,\n options: ApiRequestOptions<T> = {},\n ): Promise<T> {\n const response = await this.send<T>(method, path, options);\n\n if (!this.isExpectedStatus(response.status, options)) {\n if (response.status === 401) this.tokenProvider.invalidate();\n this.logger?.error(\n { status: response.status, method, url: response.url, body: response.data },\n \"API request failed\",\n );\n throw new ApiError(\n `API request failed: ${method} ${response.url} -> ${response.status}` +\n (options.expectStatus === undefined ? \"\" : ` (expected ${String(options.expectStatus)})`),\n {\n code: \"API_REQUEST_FAILED\",\n status: response.status,\n method,\n url: response.url,\n context: { body: response.data },\n },\n );\n }\n return response.data;\n }\n\n private isExpectedStatus(status: number, options: ApiRequestOptions<unknown>): boolean {\n if (options.expectStatus === undefined) return status >= 200 && status < 300;\n const expected = Array.isArray(options.expectStatus) ? options.expectStatus : [options.expectStatus];\n return expected.includes(status);\n }\n\n private async fetchOnce<T>(\n method: string,\n url: string,\n options: ApiRequestOptions<T>,\n ): Promise<ApiResponse<T>> {\n const headers = await this.buildHeaders(method, url, options);\n const response = await this.request.fetch(url, {\n method,\n headers,\n data: options.data,\n form: options.form,\n multipart: options.multipart,\n timeout: options.timeoutMs,\n });\n await this.interceptors.onResponse?.({ method, url, status: response.status() });\n\n return {\n status: response.status(),\n ok: response.ok(),\n headers: response.headers(),\n data: await this.parseBody<T>(response, options),\n url,\n method,\n };\n }\n\n private resolveUrl(path: string, params?: ApiRequestOptions<unknown>[\"params\"]): string {\n const base = this.baseUrl ? new URL(path, this.baseUrl).toString() : path;\n if (!params) return base;\n const url = new URL(base);\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined) url.searchParams.set(key, String(value));\n }\n return url.toString();\n }\n\n private async buildHeaders(\n method: string,\n url: string,\n options: ApiRequestOptions<unknown>,\n ): Promise<Record<string, string>> {\n let headers: Record<string, string> = { ...this.defaultHeaders, ...options.headers };\n if (!options.skipAuth) {\n const token = await this.tokenProvider.getToken();\n if (token) headers = { ...headers, Authorization: `Bearer ${token}` };\n }\n const overridden = await this.interceptors.onRequest?.({ method, url, headers });\n return overridden ?? headers;\n }\n\n private async parseBody<T>(response: APIResponse, options: ApiRequestOptions<T>): Promise<T> {\n const raw = await this.readBody(response);\n\n if (!options.schema) return raw as T;\n\n const result = options.schema.safeParse(raw);\n if (!result.success) {\n throw new ApiError(`API response did not match the expected schema: ${response.url()}`, {\n code: \"API_SCHEMA_MISMATCH\",\n status: response.status(),\n url: response.url(),\n context: { issues: result.error.issues, body: raw },\n });\n }\n return result.data;\n }\n\n private async readBody(response: APIResponse): Promise<unknown> {\n const contentType = response.headers()[\"content-type\"] ?? \"\";\n if (contentType.includes(\"application/json\")) {\n // A JSON content-type with an empty body (some 204s) would otherwise throw on parse.\n const text = await response.text();\n if (text.trim() === \"\") return undefined;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n }\n return response.text();\n }\n}\n"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { A as ApiClient, a as ApiClientOptions } from '../api-client-CpXo_NOU.cjs';
|
|
2
|
+
export { A as ApiClientInterceptors, a as ApiRequestOptions, b as ApiResponse, M as MultipartFile, R as RequestContext, c as ResponseContext } from '../types-hInbkPXf.cjs';
|
|
3
|
+
import '@playwright/test';
|
|
4
|
+
import '../types-CopwwpSW.cjs';
|
|
5
|
+
import '../retry-XqnSC8YL.cjs';
|
|
6
|
+
import 'pino';
|
|
7
|
+
import 'zod';
|
|
8
|
+
|
|
9
|
+
/** 429 (rate-limited) and any 5xx are considered transient and safe to retry. */
|
|
10
|
+
declare function isRetryableStatus(status: number): boolean;
|
|
11
|
+
|
|
12
|
+
export { isRetryableStatus };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { A as ApiClient, a as ApiClientOptions } from '../api-client-C_StjpPX.js';
|
|
2
|
+
export { A as ApiClientInterceptors, a as ApiRequestOptions, b as ApiResponse, M as MultipartFile, R as RequestContext, c as ResponseContext } from '../types-hInbkPXf.js';
|
|
3
|
+
import '@playwright/test';
|
|
4
|
+
import '../types-CopwwpSW.js';
|
|
5
|
+
import '../retry-XqnSC8YL.js';
|
|
6
|
+
import 'pino';
|
|
7
|
+
import 'zod';
|
|
8
|
+
|
|
9
|
+
/** 429 (rate-limited) and any 5xx are considered transient and safe to retry. */
|
|
10
|
+
declare function isRetryableStatus(status: number): boolean;
|
|
11
|
+
|
|
12
|
+
export { isRetryableStatus };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// src/errors/app-error.ts
|
|
2
|
+
var AppError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
context;
|
|
5
|
+
constructor(message, options) {
|
|
6
|
+
super(message, { cause: options.cause });
|
|
7
|
+
this.name = new.target.name;
|
|
8
|
+
this.code = options.code;
|
|
9
|
+
this.context = options.context;
|
|
10
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var ApiError = class extends AppError {
|
|
14
|
+
status;
|
|
15
|
+
method;
|
|
16
|
+
url;
|
|
17
|
+
constructor(message, options) {
|
|
18
|
+
super(message, options);
|
|
19
|
+
this.status = options.status;
|
|
20
|
+
this.method = options.method;
|
|
21
|
+
this.url = options.url;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/auth/token-provider.ts
|
|
26
|
+
var NullTokenProvider = class {
|
|
27
|
+
async getToken() {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
invalidate() {
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/utils/sleep.ts
|
|
35
|
+
function sleep(ms) {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/errors/retry.ts
|
|
40
|
+
function backoffDelay(attempt, minDelayMs, maxDelayMs, factor) {
|
|
41
|
+
const exponential = minDelayMs * factor ** (attempt - 1);
|
|
42
|
+
const jitter = Math.random() * minDelayMs;
|
|
43
|
+
return Math.min(exponential + jitter, maxDelayMs);
|
|
44
|
+
}
|
|
45
|
+
async function withRetry(fn, options = {}) {
|
|
46
|
+
const {
|
|
47
|
+
retries = 2,
|
|
48
|
+
minDelayMs = 200,
|
|
49
|
+
maxDelayMs = 5e3,
|
|
50
|
+
factor = 2,
|
|
51
|
+
retryOn = () => true,
|
|
52
|
+
onRetry,
|
|
53
|
+
onGiveUp
|
|
54
|
+
} = options;
|
|
55
|
+
const maxAttempts = retries + 1;
|
|
56
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return await fn();
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const isLastAttempt = attempt >= maxAttempts;
|
|
61
|
+
if (isLastAttempt || !retryOn(error)) {
|
|
62
|
+
onGiveUp?.(error, attempt);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
onRetry?.(error, attempt);
|
|
66
|
+
await sleep(backoffDelay(attempt, minDelayMs, maxDelayMs, factor));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new Error("withRetry: unreachable state");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/api/interceptors.ts
|
|
73
|
+
function isRetryableStatus(status) {
|
|
74
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/api/api-client.ts
|
|
78
|
+
var TransientHttpError = class extends Error {
|
|
79
|
+
constructor(status) {
|
|
80
|
+
super(`Transient HTTP status ${status}`);
|
|
81
|
+
this.status = status;
|
|
82
|
+
}
|
|
83
|
+
status;
|
|
84
|
+
};
|
|
85
|
+
var ApiClient = class {
|
|
86
|
+
request;
|
|
87
|
+
baseUrl;
|
|
88
|
+
tokenProvider;
|
|
89
|
+
logger;
|
|
90
|
+
retryOptions;
|
|
91
|
+
defaultHeaders;
|
|
92
|
+
interceptors;
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.request = options.request;
|
|
95
|
+
this.baseUrl = options.baseUrl;
|
|
96
|
+
this.tokenProvider = options.tokenProvider ?? new NullTokenProvider();
|
|
97
|
+
this.logger = options.logger;
|
|
98
|
+
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
99
|
+
this.interceptors = options.interceptors ?? {};
|
|
100
|
+
this.retryOptions = { ...options.retry };
|
|
101
|
+
}
|
|
102
|
+
get(path, options) {
|
|
103
|
+
return this.requestBody("GET", path, options);
|
|
104
|
+
}
|
|
105
|
+
post(path, options) {
|
|
106
|
+
return this.requestBody("POST", path, options);
|
|
107
|
+
}
|
|
108
|
+
put(path, options) {
|
|
109
|
+
return this.requestBody("PUT", path, options);
|
|
110
|
+
}
|
|
111
|
+
patch(path, options) {
|
|
112
|
+
return this.requestBody("PATCH", path, options);
|
|
113
|
+
}
|
|
114
|
+
delete(path, options) {
|
|
115
|
+
return this.requestBody("DELETE", path, options);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Full response including status and headers. Retries transient statuses, then returns
|
|
119
|
+
* whatever came back — a 404 or 500 is a normal return value here, not an exception.
|
|
120
|
+
* Network/timeout failures still throw.
|
|
121
|
+
*/
|
|
122
|
+
async send(method, path, options = {}) {
|
|
123
|
+
const url = this.resolveUrl(path, options.params);
|
|
124
|
+
let lastResponse;
|
|
125
|
+
try {
|
|
126
|
+
await withRetry(
|
|
127
|
+
async () => {
|
|
128
|
+
lastResponse = await this.fetchOnce(method, url, options);
|
|
129
|
+
if (isRetryableStatus(lastResponse.status) && !this.isExpectedStatus(lastResponse.status, options)) {
|
|
130
|
+
throw new TransientHttpError(lastResponse.status);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
...this.retryOptions,
|
|
135
|
+
retryOn: (error) => error instanceof TransientHttpError,
|
|
136
|
+
onRetry: (error, attempt) => this.logger?.warn({ method, url, attempt, err: error }, "retrying API request")
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof TransientHttpError)) throw error;
|
|
141
|
+
}
|
|
142
|
+
if (!lastResponse) {
|
|
143
|
+
throw new AppError(`API request produced no response: ${method} ${url}`, {
|
|
144
|
+
code: "API_NO_RESPONSE",
|
|
145
|
+
context: { method, url }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return lastResponse;
|
|
149
|
+
}
|
|
150
|
+
async requestBody(method, path, options = {}) {
|
|
151
|
+
const response = await this.send(method, path, options);
|
|
152
|
+
if (!this.isExpectedStatus(response.status, options)) {
|
|
153
|
+
if (response.status === 401) this.tokenProvider.invalidate();
|
|
154
|
+
this.logger?.error(
|
|
155
|
+
{ status: response.status, method, url: response.url, body: response.data },
|
|
156
|
+
"API request failed"
|
|
157
|
+
);
|
|
158
|
+
throw new ApiError(
|
|
159
|
+
`API request failed: ${method} ${response.url} -> ${response.status}` + (options.expectStatus === void 0 ? "" : ` (expected ${String(options.expectStatus)})`),
|
|
160
|
+
{
|
|
161
|
+
code: "API_REQUEST_FAILED",
|
|
162
|
+
status: response.status,
|
|
163
|
+
method,
|
|
164
|
+
url: response.url,
|
|
165
|
+
context: { body: response.data }
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return response.data;
|
|
170
|
+
}
|
|
171
|
+
isExpectedStatus(status, options) {
|
|
172
|
+
if (options.expectStatus === void 0) return status >= 200 && status < 300;
|
|
173
|
+
const expected = Array.isArray(options.expectStatus) ? options.expectStatus : [options.expectStatus];
|
|
174
|
+
return expected.includes(status);
|
|
175
|
+
}
|
|
176
|
+
async fetchOnce(method, url, options) {
|
|
177
|
+
const headers = await this.buildHeaders(method, url, options);
|
|
178
|
+
const response = await this.request.fetch(url, {
|
|
179
|
+
method,
|
|
180
|
+
headers,
|
|
181
|
+
data: options.data,
|
|
182
|
+
form: options.form,
|
|
183
|
+
multipart: options.multipart,
|
|
184
|
+
timeout: options.timeoutMs
|
|
185
|
+
});
|
|
186
|
+
await this.interceptors.onResponse?.({ method, url, status: response.status() });
|
|
187
|
+
return {
|
|
188
|
+
status: response.status(),
|
|
189
|
+
ok: response.ok(),
|
|
190
|
+
headers: response.headers(),
|
|
191
|
+
data: await this.parseBody(response, options),
|
|
192
|
+
url,
|
|
193
|
+
method
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
resolveUrl(path, params) {
|
|
197
|
+
const base = this.baseUrl ? new URL(path, this.baseUrl).toString() : path;
|
|
198
|
+
if (!params) return base;
|
|
199
|
+
const url = new URL(base);
|
|
200
|
+
for (const [key, value] of Object.entries(params)) {
|
|
201
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
202
|
+
}
|
|
203
|
+
return url.toString();
|
|
204
|
+
}
|
|
205
|
+
async buildHeaders(method, url, options) {
|
|
206
|
+
let headers = { ...this.defaultHeaders, ...options.headers };
|
|
207
|
+
if (!options.skipAuth) {
|
|
208
|
+
const token = await this.tokenProvider.getToken();
|
|
209
|
+
if (token) headers = { ...headers, Authorization: `Bearer ${token}` };
|
|
210
|
+
}
|
|
211
|
+
const overridden = await this.interceptors.onRequest?.({ method, url, headers });
|
|
212
|
+
return overridden ?? headers;
|
|
213
|
+
}
|
|
214
|
+
async parseBody(response, options) {
|
|
215
|
+
const raw = await this.readBody(response);
|
|
216
|
+
if (!options.schema) return raw;
|
|
217
|
+
const result = options.schema.safeParse(raw);
|
|
218
|
+
if (!result.success) {
|
|
219
|
+
throw new ApiError(`API response did not match the expected schema: ${response.url()}`, {
|
|
220
|
+
code: "API_SCHEMA_MISMATCH",
|
|
221
|
+
status: response.status(),
|
|
222
|
+
url: response.url(),
|
|
223
|
+
context: { issues: result.error.issues, body: raw }
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return result.data;
|
|
227
|
+
}
|
|
228
|
+
async readBody(response) {
|
|
229
|
+
const contentType = response.headers()["content-type"] ?? "";
|
|
230
|
+
if (contentType.includes("application/json")) {
|
|
231
|
+
const text = await response.text();
|
|
232
|
+
if (text.trim() === "") return void 0;
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(text);
|
|
235
|
+
} catch {
|
|
236
|
+
return text;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return response.text();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export { ApiClient, isRetryableStatus };
|
|
244
|
+
//# sourceMappingURL=index.js.map
|
|
245
|
+
//# sourceMappingURL=index.js.map
|