@bunny.net/api-client-effect-v4-runtime 0.0.0-stage → 1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/dist/mod.d.ts +22 -0
- package/dist/mod.js +23 -0
- package/dist/mod.js.map +1 -0
- package/dist/src/credentials.d.ts +26 -0
- package/dist/src/credentials.js +27 -0
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/errors.d.ts +64 -0
- package/dist/src/errors.js +35 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/operation.d.ts +99 -0
- package/dist/src/operation.js +302 -0
- package/dist/src/operation.js.map +1 -0
- package/dist/src/testing.d.ts +78 -0
- package/dist/src/testing.js +280 -0
- package/dist/src/testing.js.map +1 -0
- package/package.json +43 -3
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helpers for Bunny Effect 4 API clients, published as
|
|
3
|
+
* `@bunny.net/api-client-effect-v4-runtime/testing`.
|
|
4
|
+
*
|
|
5
|
+
* - `stubHttpClient()` — an in-memory `HttpClient` layer that records every
|
|
6
|
+
* request and answers with a canned reply.
|
|
7
|
+
* - `fetchHttpClient()` — a real `node:http` server plus `FetchHttpClient`,
|
|
8
|
+
* for exercising the same code over an actual socket.
|
|
9
|
+
* - `serviceContract(contract)` — the behaviours every generated service
|
|
10
|
+
* must honour, over both transports, as framework-agnostic cases
|
|
11
|
+
* (`{ name, run }`) you hand to `it(...)` in any test runner.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here depends on a test framework; failures throw `AssertionError`s
|
|
14
|
+
* from `node:assert`.
|
|
15
|
+
*/
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { Effect, Layer, Result } from "effect";
|
|
18
|
+
import { FetchHttpClient, HttpClient, HttpClientError, HttpClientResponse, } from "effect/http";
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { BunnyCredentials } from "./credentials.js";
|
|
21
|
+
import { BunnyResponseError, BunnyUnexpectedStatusError } from "./errors.js";
|
|
22
|
+
/** The canned reply as status + wire body + headers (content-type set from the body's kind). */
|
|
23
|
+
const render = (reply) => {
|
|
24
|
+
const { text, contentType } = reply.body === undefined
|
|
25
|
+
? { text: undefined, contentType: undefined }
|
|
26
|
+
: typeof reply.body === "string"
|
|
27
|
+
? { text: reply.body, contentType: "text/plain" }
|
|
28
|
+
: { text: JSON.stringify(reply.body), contentType: "application/json" };
|
|
29
|
+
return {
|
|
30
|
+
status: reply.status,
|
|
31
|
+
text,
|
|
32
|
+
headers: {
|
|
33
|
+
...(contentType && { "content-type": contentType }),
|
|
34
|
+
...reply.headers,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const lowerHeaders = (headers) => {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
41
|
+
if (v !== undefined) {
|
|
42
|
+
out[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
/** The reply queue shared by both transports: `reply(...)` sets it, `next(request)` reads it. */
|
|
48
|
+
const replyQueue = () => {
|
|
49
|
+
let next = () => ({ status: 200 });
|
|
50
|
+
return {
|
|
51
|
+
next: (request) => render(next(request)),
|
|
52
|
+
reply: (r) => {
|
|
53
|
+
next = typeof r === "function" ? r : () => r;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/** An `HttpClient` that never touches the network. */
|
|
58
|
+
export const stubHttpClient = () => {
|
|
59
|
+
const requests = [];
|
|
60
|
+
const queue = replyQueue();
|
|
61
|
+
let fail = false;
|
|
62
|
+
const client = HttpClient.make((request, url) => Effect.suspend(() => {
|
|
63
|
+
const recorded = {
|
|
64
|
+
method: request.method,
|
|
65
|
+
url: url.toString(),
|
|
66
|
+
headers: lowerHeaders(request.headers),
|
|
67
|
+
};
|
|
68
|
+
requests.push(recorded);
|
|
69
|
+
if (fail) {
|
|
70
|
+
return Effect.fail(new HttpClientError.HttpClientError({
|
|
71
|
+
reason: new HttpClientError.TransportError({
|
|
72
|
+
request,
|
|
73
|
+
cause: new Error("stub: connection refused"),
|
|
74
|
+
}),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
const { status, text, headers } = queue.next(recorded);
|
|
78
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(text, { status, headers })));
|
|
79
|
+
}));
|
|
80
|
+
return {
|
|
81
|
+
layer: Layer.succeed(HttpClient.HttpClient, client),
|
|
82
|
+
requests,
|
|
83
|
+
reply: queue.reply,
|
|
84
|
+
failTransport: () => {
|
|
85
|
+
fail = true;
|
|
86
|
+
},
|
|
87
|
+
close: () => Promise.resolve(),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
/** A loopback `node:http` server driven through `effect/http`'s `FetchHttpClient`. */
|
|
91
|
+
export const fetchHttpClient = async () => {
|
|
92
|
+
const requests = [];
|
|
93
|
+
const queue = replyQueue();
|
|
94
|
+
let closed = false;
|
|
95
|
+
const shutdown = () => {
|
|
96
|
+
if (closed)
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
closed = true;
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
server.closeAllConnections?.();
|
|
101
|
+
server.close(() => resolve());
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const server = createServer((req, res) => {
|
|
105
|
+
const recorded = {
|
|
106
|
+
method: req.method ?? "GET",
|
|
107
|
+
url: `http://${req.headers.host}${req.url ?? "/"}`,
|
|
108
|
+
headers: lowerHeaders(req.headers),
|
|
109
|
+
};
|
|
110
|
+
req.resume();
|
|
111
|
+
req.on("end", () => {
|
|
112
|
+
requests.push(recorded);
|
|
113
|
+
const { status, text, headers } = queue.next(recorded);
|
|
114
|
+
res.writeHead(status, headers);
|
|
115
|
+
res.end(text);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
119
|
+
const { port } = server.address();
|
|
120
|
+
return {
|
|
121
|
+
layer: FetchHttpClient.layer,
|
|
122
|
+
requests,
|
|
123
|
+
reply: queue.reply,
|
|
124
|
+
// Stop listening, so every further connection is refused: the one
|
|
125
|
+
// transport failure that behaves the same under Node, Deno and Bun.
|
|
126
|
+
failTransport: () => {
|
|
127
|
+
void shutdown();
|
|
128
|
+
},
|
|
129
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
130
|
+
close: shutdown,
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
export const makeTransport = (transport) => transport === "stub" ? Promise.resolve(stubHttpClient()) : fetchHttpClient();
|
|
134
|
+
const KEY = "contract-secret-key-9f1c";
|
|
135
|
+
/** JSON with object keys sorted at every depth: equality of decoded values must not depend on property order. */
|
|
136
|
+
const canonicalJson = (value) => JSON.stringify(value, (_key, v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
137
|
+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
|
138
|
+
: v);
|
|
139
|
+
const assertSameJson = (actual, expected, what) => {
|
|
140
|
+
const a = canonicalJson(actual);
|
|
141
|
+
const e = canonicalJson(expected);
|
|
142
|
+
assert.equal(a, e, `${what}: expected ${e}, got ${a}`);
|
|
143
|
+
};
|
|
144
|
+
const sortedQuery = (url) => [...url.searchParams.entries()].sort(([a, b], [c, d]) => a.localeCompare(c) || b.localeCompare(d));
|
|
145
|
+
const assertRedacted = (error) => {
|
|
146
|
+
const rendered = [
|
|
147
|
+
String(error),
|
|
148
|
+
JSON.stringify(error) ?? "",
|
|
149
|
+
error?.message ?? "",
|
|
150
|
+
error?.stack ?? "",
|
|
151
|
+
].join("\n");
|
|
152
|
+
assert.ok(!rendered.includes(KEY), "the credential leaked into an error's rendering");
|
|
153
|
+
};
|
|
154
|
+
const trimSlash = (path) => path.replace(/\/+$/, "");
|
|
155
|
+
/**
|
|
156
|
+
* Build the contract cases for one service: over both transports when
|
|
157
|
+
* `transport` is omitted (case names are prefixed `[stub]` / `[fetch]`), or
|
|
158
|
+
* over one. Every case sets up its own transport, so cases are independent
|
|
159
|
+
* and can run in any order.
|
|
160
|
+
*/
|
|
161
|
+
export const serviceContract = (contract, transport) => {
|
|
162
|
+
if (transport === undefined) {
|
|
163
|
+
return ["stub", "fetch"].flatMap((t) => serviceContract(contract, t).map((c) => ({
|
|
164
|
+
...c,
|
|
165
|
+
name: `[${t}] ${c.name}`,
|
|
166
|
+
})));
|
|
167
|
+
}
|
|
168
|
+
const { service, operation: op } = contract;
|
|
169
|
+
const authHeader = (op.authHeader ?? "AccessKey").toLowerCase();
|
|
170
|
+
const documented = new URL(op.url);
|
|
171
|
+
const serverPath = trimSlash(new URL(op.server ?? documented.origin).pathname);
|
|
172
|
+
if (!documented.pathname.startsWith(serverPath)) {
|
|
173
|
+
throw new Error(`operation.url ${op.url} does not start with server ${op.server}`);
|
|
174
|
+
}
|
|
175
|
+
/** The operation's own path, i.e. the documented path minus the server's path. */
|
|
176
|
+
const operationPath = documented.pathname.slice(serverPath.length);
|
|
177
|
+
const withTransport = async (body) => {
|
|
178
|
+
const t = await makeTransport(transport);
|
|
179
|
+
try {
|
|
180
|
+
return await body(t);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await t.close();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const call = (t, options) => Effect.runPromise(Effect.result(op.call).pipe(
|
|
187
|
+
// The stub accepts any host, so it can exercise the `Default` layer (documented server) itself.
|
|
188
|
+
Effect.provide(Layer.provide(options !== undefined || t.baseUrl !== undefined
|
|
189
|
+
? service.layer({ baseUrl: t.baseUrl, ...options })
|
|
190
|
+
: service.Default, Layer.merge(t.layer, BunnyCredentials.layer(KEY))))));
|
|
191
|
+
const expectedUrl = (t) => {
|
|
192
|
+
if (!t.baseUrl)
|
|
193
|
+
return documented;
|
|
194
|
+
const u = new URL(t.baseUrl);
|
|
195
|
+
u.pathname = operationPath;
|
|
196
|
+
u.search = documented.search;
|
|
197
|
+
return u;
|
|
198
|
+
};
|
|
199
|
+
return [
|
|
200
|
+
{
|
|
201
|
+
name: "sends the documented method, path and query, authenticating with the API-key header only",
|
|
202
|
+
run: () => withTransport(async (t) => {
|
|
203
|
+
t.reply({ status: op.success.status, body: op.success.body });
|
|
204
|
+
await call(t);
|
|
205
|
+
assert.equal(t.requests.length, 1, `expected one request, saw ${t.requests.length}`);
|
|
206
|
+
const req = t.requests[0];
|
|
207
|
+
const actual = new URL(req.url);
|
|
208
|
+
const expected = expectedUrl(t);
|
|
209
|
+
assert.equal(req.method, op.method, "method");
|
|
210
|
+
assert.equal(actual.host, expected.host, "host");
|
|
211
|
+
assert.equal(actual.pathname, expected.pathname, "path");
|
|
212
|
+
assertSameJson(sortedQuery(actual), sortedQuery(expected), "query");
|
|
213
|
+
assert.equal(req.headers[authHeader], KEY, `${authHeader} header must carry the key`);
|
|
214
|
+
assert.equal(req.headers["authorization"], undefined, "no Authorization header is sent for an API key");
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "decodes a documented success body into { status, value }",
|
|
219
|
+
run: () => withTransport(async (t) => {
|
|
220
|
+
t.reply({ status: op.success.status, body: op.success.body });
|
|
221
|
+
const result = await call(t);
|
|
222
|
+
assert.ok(Result.isSuccess(result), `expected Success, got ${JSON.stringify(result)}`);
|
|
223
|
+
assert.equal(result.success.status, op.success.status, "status");
|
|
224
|
+
assertSameJson(result.success.value, op.success.value ?? op.success.body, "decoded value");
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
// Only when the contract names one: a case that returns early would
|
|
228
|
+
// show as green without having run.
|
|
229
|
+
...(op.error === undefined ? [] : [{
|
|
230
|
+
name: "fails with a typed BunnyResponseError on a documented error status",
|
|
231
|
+
run: () => withTransport(async (t) => {
|
|
232
|
+
const error = op.error;
|
|
233
|
+
t.reply({ status: error.status, body: error.body });
|
|
234
|
+
const result = await call(t);
|
|
235
|
+
assert.ok(Result.isFailure(result), "expected Failure");
|
|
236
|
+
const err = result.failure;
|
|
237
|
+
assert.ok(err instanceof BunnyResponseError, `expected BunnyResponseError, got ${String(err)}`);
|
|
238
|
+
assert.equal(err.response.status, error.status, "error carries the status");
|
|
239
|
+
assertRedacted(err);
|
|
240
|
+
}),
|
|
241
|
+
}]),
|
|
242
|
+
{
|
|
243
|
+
name: "fails with BunnyUnexpectedStatusError on an undocumented status",
|
|
244
|
+
run: () => withTransport(async (t) => {
|
|
245
|
+
t.reply({ status: 418, body: "short and stout" });
|
|
246
|
+
const result = await call(t);
|
|
247
|
+
assert.ok(Result.isFailure(result), "expected Failure");
|
|
248
|
+
const err = result.failure;
|
|
249
|
+
assert.ok(err instanceof BunnyUnexpectedStatusError, `expected BunnyUnexpectedStatusError, got ${String(err)}`);
|
|
250
|
+
assert.equal(err.status, 418, "error carries the status");
|
|
251
|
+
assertRedacted(err);
|
|
252
|
+
}),
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "surfaces a transport failure as an HttpClientError without revealing the credential",
|
|
256
|
+
run: () => withTransport(async (t) => {
|
|
257
|
+
t.failTransport();
|
|
258
|
+
const result = await call(t);
|
|
259
|
+
assert.ok(Result.isFailure(result), "expected Failure");
|
|
260
|
+
const err = result.failure;
|
|
261
|
+
assert.ok(HttpClientError.isHttpClientError(err), `expected HttpClientError, got ${String(err)}`);
|
|
262
|
+
assertRedacted(err);
|
|
263
|
+
}),
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "ClientOptions.baseUrl replaces the documented server, keeping the path",
|
|
267
|
+
run: () => withTransport(async (t) => {
|
|
268
|
+
const baseUrl = t.baseUrl
|
|
269
|
+
? `${t.baseUrl}/override/`
|
|
270
|
+
: "https://override.test/api/";
|
|
271
|
+
t.reply({ status: op.success.status, body: op.success.body });
|
|
272
|
+
await call(t, { baseUrl });
|
|
273
|
+
const actual = new URL(t.requests[0].url);
|
|
274
|
+
assert.equal(actual.pathname, `${trimSlash(new URL(baseUrl).pathname)}${operationPath}`, "path");
|
|
275
|
+
assert.equal(actual.host, new URL(baseUrl).host, "host follows the override");
|
|
276
|
+
}),
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
};
|
|
280
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC/C,OAAO,EACL,eAAe,EACf,UAAU,EACV,eAAe,EACf,kBAAkB,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,YAAY,EAAe,MAAM,WAAW,CAAC;AAEtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAkC7E,gGAAgG;AAChG,MAAM,MAAM,GAAG,CACb,KAAY,EAKZ,EAAE;IACF,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC,IAAI,KAAK,SAAS;QACpD,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE;QAC7C,CAAC,CAAC,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ;YAChC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,WAAW,EAAE,YAAY,EAAE;YACjD,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAC1E,OAAO;QACL,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,IAAI;QACJ,OAAO,EAAE;YACP,GAAG,CAAC,WAAW,IAAI,EAAE,cAAc,EAAE,WAAW,EAAE,CAAC;YACnD,GAAG,KAAK,CAAC,OAAO;SACjB;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,YAAY,GAAG,CACnB,OAAsD,EAC9B,EAAE;IAC1B,MAAM,GAAG,GAA2B,EAAE,CAAC;IACvC,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC7C,IAAI,CAAC,KAAK,SAAS,EAAE,CAAC;YACpB,GAAG,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC,CAAC;AAEF,iGAAiG;AACjG,MAAM,UAAU,GAAG,GAAG,EAAE;IACtB,IAAI,IAAI,GAAwC,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IACxE,OAAO;QACL,IAAI,EAAE,CAAC,OAAwB,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACzD,KAAK,EAAE,CAAC,CAAgD,EAAE,EAAE;YAC1D,IAAI,GAAG,OAAO,CAAC,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;QAC/C,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,sDAAsD;AACtD,MAAM,CAAC,MAAM,cAAc,GAAG,GAAkB,EAAE;IAChD,MAAM,QAAQ,GAAsB,EAAE,CAAC;IACvC,MAAM,KAAK,GAAG,UAAU,EAAE,CAAC;IAC3B,IAAI,IAAI,GAAG,KAAK,CAAC;IACjB,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,CAC9C,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;QAClB,MAAM,QAAQ,GAAoB;YAChC,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE;YACnB,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,OAAO,CAAC;SACvC,CAAC;QACF,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACxB,IAAI,IAAI,EAAE,CAAC;YACT,OAAO,MAAM,CAAC,IAAI,CAChB,IAAI,eAAe,CAAC,eAAe,CAAC;gBAClC,MAAM,EAAE,IAAI,eAAe,CAAC,cAAc,CAAC;oBACzC,OAAO;oBACP,KAAK,EAAE,IAAI,KAAK,CAAC,0BAA0B,CAAC;iBAC7C,CAAC;aACH,CAAC,CACH,CAAC;QACJ,CAAC;QACD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACvD,OAAO,MAAM,CAAC,OAAO,CACnB,kBAAkB,CAAC,OAAO,CACxB,OAAO,EACP,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CACxC,CACF,CAAC;IACJ,CAAC,CAAC,CACH,CAAC;IACF,OAAO;QACL,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,UAAU,EAAE,MAAM,CAAC;QACnD,QAAQ;QACR,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,aAAa,EAAE,GAAG,EAAE;YAClB,IAAI,GAAG,IAAI,CAAC;QACd,CAAC;QACD,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE;KAC/B,CAAC;AACJ,CAAC,CAAC;AAEF,sFAAsF;AACtF,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,IAA4B,EAAE;IAChE,MAAM,QAAQ,GAAsB,EAAE,CAAC;IACvC,MAAM,KAAK,GAAG,UAAU,EAAE,CAAC;IAC3B,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,MAAM,QAAQ,GAAG,GAAkB,EAAE;QACnC,IAAI,MAAM;YAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QACrC,MAAM,GAAG,IAAI,CAAC;QACd,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;YACnC,MAAM,CAAC,mBAAmB,EAAE,EAAE,CAAC;YAC/B,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC;QAChC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC;IACF,MAAM,MAAM,GAAW,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAC/C,MAAM,QAAQ,GAAoB;YAChC,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,KAAK;YAC3B,GAAG,EAAE,UAAU,GAAG,CAAC,OAAO,CAAC,IAAI,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE;YAClD,OAAO,EAAE,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC;SACnC,CAAC;QACF,GAAG,CAAC,MAAM,EAAE,CAAC;QACb,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;YACjB,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACxB,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACvD,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAC/B,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IACH,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAClC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAC/C,CAAC;IACF,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,OAAO,EAAiB,CAAC;IACjD,OAAO;QACL,KAAK,EAAE,eAAe,CAAC,KAAK;QAC5B,QAAQ;QACR,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,kEAAkE;QAClE,oEAAoE;QACpE,aAAa,EAAE,GAAG,EAAE;YAClB,KAAK,QAAQ,EAAE,CAAC;QAClB,CAAC;QACD,OAAO,EAAE,oBAAoB,IAAI,EAAE;QACnC,KAAK,EAAE,QAAQ;KAChB,CAAC;AACJ,CAAC,CAAC;AAIF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,SAAoB,EAA0B,EAAE,CAC5E,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;AAyD/E,MAAM,GAAG,GAAG,0BAA0B,CAAC;AAEvC,iHAAiH;AACjH,MAAM,aAAa,GAAG,CAAC,KAAc,EAAU,EAAE,CAC/C,IAAI,CAAC,SAAS,CACZ,KAAK,EACL,CAAC,IAAI,EAAE,CAAU,EAAE,EAAE,CACnB,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,CAAC,CAAC,MAAM,CAAC,WAAW,CAClB,MAAM,CAAC,OAAO,CAAC,CAA4B,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAC7D,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAC3B,CACF;IACD,CAAC,CAAC,CAAC,CACR,CAAC;AAEJ,MAAM,cAAc,GAAG,CACrB,MAAe,EACf,QAAiB,EACjB,IAAY,EACN,EAAE;IACR,MAAM,CAAC,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IAChC,MAAM,CAAC,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IAClC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,IAAI,cAAc,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;AACzD,CAAC,CAAC;AAEF,MAAM,WAAW,GAAG,CAAC,GAAQ,EAA2B,EAAE,CACxD,CAAC,GAAG,GAAG,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CACtD,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CACzC,CAAC;AAEJ,MAAM,cAAc,GAAG,CAAC,KAAc,EAAQ,EAAE;IAC9C,MAAM,QAAQ,GAAG;QACf,MAAM,CAAC,KAAK,CAAC;QACb,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,EAAE;QAC1B,KAA8B,EAAE,OAAO,IAAI,EAAE;QAC7C,KAA4B,EAAE,KAAK,IAAI,EAAE;KAC3C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACb,MAAM,CAAC,EAAE,CACP,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EACvB,iDAAiD,CAClD,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,SAAS,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AAErE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,QAA6B,EAC7B,SAAqB,EACL,EAAE;IAClB,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,OAAQ,CAAC,MAAM,EAAE,OAAO,CAAW,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAChD,eAAe,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACvC,GAAG,CAAC;YACJ,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE;SACzB,CAAC,CAAC,CACJ,CAAC;IACJ,CAAC;IACD,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,EAAE,GAAG,QAAQ,CAAC;IAC5C,MAAM,UAAU,GAAG,CAAC,EAAE,CAAC,UAAU,IAAI,WAAW,CAAC,CAAC,WAAW,EAAE,CAAC;IAChE,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACnC,MAAM,UAAU,GAAG,SAAS,CAC1B,IAAI,GAAG,CAAC,EAAE,CAAC,MAAM,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC,QAAQ,CACjD,CAAC;IACF,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QAChD,MAAM,IAAI,KAAK,CACb,iBAAiB,EAAE,CAAC,GAAG,+BAA+B,EAAE,CAAC,MAAM,EAAE,CAClE,CAAC;IACJ,CAAC;IACD,kFAAkF;IAClF,MAAM,aAAa,GAAG,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IAEnE,MAAM,aAAa,GAAG,KAAK,EACzB,IAAsC,EAC1B,EAAE;QACd,MAAM,CAAC,GAAG,MAAM,aAAa,CAAC,SAAS,CAAC,CAAC;QACzC,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC;QACvB,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,CAAC,KAAK,EAAE,CAAC;QAClB,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,IAAI,GAAG,CAAC,CAAgB,EAAE,OAAuB,EAAE,EAAE,CACzD,MAAM,CAAC,UAAU,CACf,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI;IACzB,gGAAgG;IAChG,MAAM,CAAC,OAAO,CACZ,KAAK,CAAC,OAAO,CACX,OAAO,KAAK,SAAS,IAAI,CAAC,CAAC,OAAO,KAAK,SAAS;QAC9C,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,GAAG,OAAO,EAAE,CAAC;QACnD,CAAC,CAAC,OAAO,CAAC,OAAO,EACnB,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,EAAE,gBAAgB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAClD,CACF,CACF,CACF,CAAC;IAEJ,MAAM,WAAW,GAAG,CAAC,CAAgB,EAAO,EAAE;QAC5C,IAAI,CAAC,CAAC,CAAC,OAAO;YAAE,OAAO,UAAU,CAAC;QAClC,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;QAC7B,CAAC,CAAC,QAAQ,GAAG,aAAa,CAAC;QAC3B,CAAC,CAAC,MAAM,GAAG,UAAU,CAAC,MAAM,CAAC;QAC7B,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;IAEF,OAAO;QACL;YACE,IAAI,EACF,0FAA0F;YAC5F,GAAG,EAAE,GAAG,EAAE,CACR,aAAa,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;gBACxB,CAAC,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC9D,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC;gBACd,MAAM,CAAC,KAAK,CACV,CAAC,CAAC,QAAQ,CAAC,MAAM,EACjB,CAAC,EACD,6BAA6B,CAAC,CAAC,QAAQ,CAAC,MAAM,EAAE,CACjD,CAAC;gBACF,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAE,CAAC;gBAC3B,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBAChC,MAAM,QAAQ,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;gBAChC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;gBAC9C,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;gBACjD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;gBACzD,cAAc,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;gBACpE,MAAM,CAAC,KAAK,CACV,GAAG,CAAC,OAAO,CAAC,UAAU,CAAC,EACvB,GAAG,EACH,GAAG,UAAU,4BAA4B,CAC1C,CAAC;gBACF,MAAM,CAAC,KAAK,CACV,GAAG,CAAC,OAAO,CAAC,eAAe,CAAC,EAC5B,SAAS,EACT,gDAAgD,CACjD,CAAC;YACJ,CAAC,CAAC;SACL;QACD;YACE,IAAI,EAAE,0DAA0D;YAChE,GAAG,EAAE,GAAG,EAAE,CACR,aAAa,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;gBACxB,CAAC,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC9D,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC;gBAC7B,MAAM,CAAC,EAAE,CACP,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EACxB,yBAAyB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAClD,CAAC;gBACF,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;gBACjE,cAAc,CACZ,MAAM,CAAC,OAAO,CAAC,KAAK,EACpB,EAAE,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,OAAO,CAAC,IAAI,EACnC,eAAe,CAChB,CAAC;YACJ,CAAC,CAAC;SACL;QACD,oEAAoE;QACpE,oCAAoC;QACpC,GAAG,CAAC,EAAE,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;gBACjC,IAAI,EACF,oEAAoE;gBACtE,GAAG,EAAE,GAAG,EAAE,CACR,aAAa,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;oBACxB,MAAM,KAAK,GAAG,EAAE,CAAC,KAAM,CAAC;oBACxB,CAAC,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;oBACpD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC;oBAC7B,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC,CAAC;oBACxD,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC;oBAC3B,MAAM,CAAC,EAAE,CACP,GAAG,YAAY,kBAAkB,EACjC,oCAAoC,MAAM,CAAC,GAAG,CAAC,EAAE,CAClD,CAAC;oBACF,MAAM,CAAC,KAAK,CACV,GAAG,CAAC,QAAQ,CAAC,MAAM,EACnB,KAAK,CAAC,MAAM,EACZ,0BAA0B,CAC3B,CAAC;oBACF,cAAc,CAAC,GAAG,CAAC,CAAC;gBACtB,CAAC,CAAC;aACL,CAAC,CAAC;QACH;YACE,IAAI,EAAE,iEAAiE;YACvE,GAAG,EAAE,GAAG,EAAE,CACR,aAAa,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;gBACxB,CAAC,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,CAAC;gBAClD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC;gBAC7B,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC,CAAC;gBACxD,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC;gBAC3B,MAAM,CAAC,EAAE,CACP,GAAG,YAAY,0BAA0B,EACzC,4CAA4C,MAAM,CAAC,GAAG,CAAC,EAAE,CAC1D,CAAC;gBACF,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,0BAA0B,CAAC,CAAC;gBAC1D,cAAc,CAAC,GAAG,CAAC,CAAC;YACtB,CAAC,CAAC;SACL;QACD;YACE,IAAI,EACF,qFAAqF;YACvF,GAAG,EAAE,GAAG,EAAE,CACR,aAAa,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;gBACxB,CAAC,CAAC,aAAa,EAAE,CAAC;gBAClB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC;gBAC7B,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC,CAAC;gBACxD,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC;gBAC3B,MAAM,CAAC,EAAE,CACP,eAAe,CAAC,iBAAiB,CAAC,GAAG,CAAC,EACtC,iCAAiC,MAAM,CAAC,GAAG,CAAC,EAAE,CAC/C,CAAC;gBACF,cAAc,CAAC,GAAG,CAAC,CAAC;YACtB,CAAC,CAAC;SACL;QACD;YACE,IAAI,EACF,wEAAwE;YAC1E,GAAG,EAAE,GAAG,EAAE,CACR,aAAa,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;gBACxB,MAAM,OAAO,GAAG,CAAC,CAAC,OAAO;oBACvB,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,YAAY;oBAC1B,CAAC,CAAC,4BAA4B,CAAC;gBACjC,CAAC,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC9D,MAAM,IAAI,CAAC,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;gBAC3B,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAE,CAAC,GAAG,CAAC,CAAC;gBAC3C,MAAM,CAAC,KAAK,CACV,MAAM,CAAC,QAAQ,EACf,GAAG,SAAS,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,aAAa,EAAE,EACzD,MAAM,CACP,CAAC;gBACF,MAAM,CAAC,KAAK,CACV,MAAM,CAAC,IAAI,EACX,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,EACrB,2BAA2B,CAC5B,CAAC;YACJ,CAAC,CAAC;SACL;KACF,CAAC;AACJ,CAAC,CAAC","sourcesContent":["/**\n * Test helpers for Bunny Effect 4 API clients, published as\n * `@bunny.net/api-client-effect-v4-runtime/testing`.\n *\n * - `stubHttpClient()` — an in-memory `HttpClient` layer that records every\n * request and answers with a canned reply.\n * - `fetchHttpClient()` — a real `node:http` server plus `FetchHttpClient`,\n * for exercising the same code over an actual socket.\n * - `serviceContract(contract)` — the behaviours every generated service\n * must honour, over both transports, as framework-agnostic cases\n * (`{ name, run }`) you hand to `it(...)` in any test runner.\n *\n * Nothing here depends on a test framework; failures throw `AssertionError`s\n * from `node:assert`.\n */\nimport assert from \"node:assert/strict\";\nimport { Effect, Layer, Result } from \"effect\";\nimport {\n FetchHttpClient,\n HttpClient,\n HttpClientError,\n HttpClientResponse,\n} from \"effect/http\";\nimport { createServer, type Server } from \"node:http\";\nimport type { AddressInfo } from \"node:net\";\nimport { BunnyCredentials } from \"./credentials.ts\";\nimport { BunnyResponseError, BunnyUnexpectedStatusError } from \"./errors.ts\";\nimport type { ClientOptions } from \"./operation.ts\";\n\n// ---------------------------------------------------------------------------\n// Transports\n// ---------------------------------------------------------------------------\n\nexport interface RecordedRequest {\n readonly method: string;\n /** Absolute URL including the query string. */\n readonly url: string;\n /** Header names lower-cased. */\n readonly headers: Record<string, string>;\n}\n\nexport interface Reply {\n readonly status: number;\n /** JSON-encoded when an object; sent verbatim when a string; nothing when undefined. */\n readonly body?: unknown;\n readonly headers?: Record<string, string>;\n}\n\nexport interface TestTransport {\n readonly layer: Layer.Layer<HttpClient.HttpClient>;\n readonly requests: RecordedRequest[];\n /** Queue the reply (or a function of the request) for every following request. Defaults to 200 with no body. */\n reply(next: Reply | ((request: RecordedRequest) => Reply)): void;\n /** Make every further request fail at the transport level (connection refused / socket error). */\n failTransport(): void;\n /** Base URL to point the service at; undefined for the stub (which accepts any host). */\n readonly baseUrl?: string;\n close(): Promise<void>;\n}\n\n/** The canned reply as status + wire body + headers (content-type set from the body's kind). */\nconst render = (\n reply: Reply,\n): {\n status: number;\n text: string | undefined;\n headers: Record<string, string>;\n} => {\n const { text, contentType } = reply.body === undefined\n ? { text: undefined, contentType: undefined }\n : typeof reply.body === \"string\"\n ? { text: reply.body, contentType: \"text/plain\" }\n : { text: JSON.stringify(reply.body), contentType: \"application/json\" };\n return {\n status: reply.status,\n text,\n headers: {\n ...(contentType && { \"content-type\": contentType }),\n ...reply.headers,\n },\n };\n};\n\nconst lowerHeaders = (\n headers: Record<string, string | string[] | undefined>,\n): Record<string, string> => {\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(headers)) {\n if (v !== undefined) {\n out[k.toLowerCase()] = Array.isArray(v) ? v.join(\", \") : v;\n }\n }\n return out;\n};\n\n/** The reply queue shared by both transports: `reply(...)` sets it, `next(request)` reads it. */\nconst replyQueue = () => {\n let next: (request: RecordedRequest) => Reply = () => ({ status: 200 });\n return {\n next: (request: RecordedRequest) => render(next(request)),\n reply: (r: Reply | ((request: RecordedRequest) => Reply)) => {\n next = typeof r === \"function\" ? r : () => r;\n },\n };\n};\n\n/** An `HttpClient` that never touches the network. */\nexport const stubHttpClient = (): TestTransport => {\n const requests: RecordedRequest[] = [];\n const queue = replyQueue();\n let fail = false;\n const client = HttpClient.make((request, url) =>\n Effect.suspend(() => {\n const recorded: RecordedRequest = {\n method: request.method,\n url: url.toString(),\n headers: lowerHeaders(request.headers),\n };\n requests.push(recorded);\n if (fail) {\n return Effect.fail(\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n cause: new Error(\"stub: connection refused\"),\n }),\n }),\n );\n }\n const { status, text, headers } = queue.next(recorded);\n return Effect.succeed(\n HttpClientResponse.fromWeb(\n request,\n new Response(text, { status, headers }),\n ),\n );\n })\n );\n return {\n layer: Layer.succeed(HttpClient.HttpClient, client),\n requests,\n reply: queue.reply,\n failTransport: () => {\n fail = true;\n },\n close: () => Promise.resolve(),\n };\n};\n\n/** A loopback `node:http` server driven through `effect/http`'s `FetchHttpClient`. */\nexport const fetchHttpClient = async (): Promise<TestTransport> => {\n const requests: RecordedRequest[] = [];\n const queue = replyQueue();\n let closed = false;\n const shutdown = (): Promise<void> => {\n if (closed) return Promise.resolve();\n closed = true;\n return new Promise<void>((resolve) => {\n server.closeAllConnections?.();\n server.close(() => resolve());\n });\n };\n const server: Server = createServer((req, res) => {\n const recorded: RecordedRequest = {\n method: req.method ?? \"GET\",\n url: `http://${req.headers.host}${req.url ?? \"/\"}`,\n headers: lowerHeaders(req.headers),\n };\n req.resume();\n req.on(\"end\", () => {\n requests.push(recorded);\n const { status, text, headers } = queue.next(recorded);\n res.writeHead(status, headers);\n res.end(text);\n });\n });\n await new Promise<void>((resolve) =>\n server.listen(0, \"127.0.0.1\", () => resolve())\n );\n const { port } = server.address() as AddressInfo;\n return {\n layer: FetchHttpClient.layer,\n requests,\n reply: queue.reply,\n // Stop listening, so every further connection is refused: the one\n // transport failure that behaves the same under Node, Deno and Bun.\n failTransport: () => {\n void shutdown();\n },\n baseUrl: `http://127.0.0.1:${port}`,\n close: shutdown,\n };\n};\n\nexport type Transport = \"stub\" | \"fetch\";\n\nexport const makeTransport = (transport: Transport): Promise<TestTransport> =>\n transport === \"stub\" ? Promise.resolve(stubHttpClient()) : fetchHttpClient();\n\n// ---------------------------------------------------------------------------\n// The service contract\n// ---------------------------------------------------------------------------\n\n/** The static side every generated `<Name>` service class has. */\nexport interface ServiceClass<Id> {\n readonly Default: Layer.Layer<\n Id,\n never,\n HttpClient.HttpClient | BunnyCredentials\n >;\n readonly layer: (\n options: ClientOptions,\n ) => Layer.Layer<Id, never, HttpClient.HttpClient | BunnyCredentials>;\n}\n\nexport interface ServiceContract<Id> {\n readonly service: ServiceClass<Id>;\n /** One representative read operation, with the request the spec documents for it. */\n readonly operation: {\n /** The call, e.g. `Effect.flatMap(BunnyCore, (core) => core.pullZonePublicIndex({ path: { id: 7 } }))`. */\n readonly call: Effect.Effect<\n { readonly status: number; readonly value: unknown },\n unknown,\n Id\n >;\n readonly method:\n | \"GET\"\n | \"POST\"\n | \"PUT\"\n | \"PATCH\"\n | \"DELETE\"\n | \"HEAD\"\n | \"OPTIONS\";\n /** The documented absolute URL, query string included. */\n readonly url: string;\n /** The spec's server URL when it carries a path (e.g. `https://host/api/mc`); defaults to the URL's origin. */\n readonly server?: string;\n /** The spec's API-key header name; Bunny's APIs all use `AccessKey`. */\n readonly authHeader?: string;\n readonly success: {\n readonly status: number;\n readonly body?: unknown;\n readonly value?: unknown;\n };\n /** A documented error status, when the operation has one. */\n readonly error?: { readonly status: number; readonly body?: unknown };\n };\n}\n\nexport interface ContractCase {\n readonly name: string;\n readonly run: () => Promise<void>;\n}\n\nconst KEY = \"contract-secret-key-9f1c\";\n\n/** JSON with object keys sorted at every depth: equality of decoded values must not depend on property order. */\nconst canonicalJson = (value: unknown): string =>\n JSON.stringify(\n value,\n (_key, v: unknown) =>\n v !== null && typeof v === \"object\" && !Array.isArray(v)\n ? Object.fromEntries(\n Object.entries(v as Record<string, unknown>).sort(([a], [b]) =>\n a < b ? -1 : a > b ? 1 : 0\n ),\n )\n : v,\n );\n\nconst assertSameJson = (\n actual: unknown,\n expected: unknown,\n what: string,\n): void => {\n const a = canonicalJson(actual);\n const e = canonicalJson(expected);\n assert.equal(a, e, `${what}: expected ${e}, got ${a}`);\n};\n\nconst sortedQuery = (url: URL): Array<[string, string]> =>\n [...url.searchParams.entries()].sort(([a, b], [c, d]) =>\n a.localeCompare(c) || b.localeCompare(d)\n );\n\nconst assertRedacted = (error: unknown): void => {\n const rendered = [\n String(error),\n JSON.stringify(error) ?? \"\",\n (error as { message?: string })?.message ?? \"\",\n (error as { stack?: string })?.stack ?? \"\",\n ].join(\"\\n\");\n assert.ok(\n !rendered.includes(KEY),\n \"the credential leaked into an error's rendering\",\n );\n};\n\nconst trimSlash = (path: string): string => path.replace(/\\/+$/, \"\");\n\n/**\n * Build the contract cases for one service: over both transports when\n * `transport` is omitted (case names are prefixed `[stub]` / `[fetch]`), or\n * over one. Every case sets up its own transport, so cases are independent\n * and can run in any order.\n */\nexport const serviceContract = <Id>(\n contract: ServiceContract<Id>,\n transport?: Transport,\n): ContractCase[] => {\n if (transport === undefined) {\n return ([\"stub\", \"fetch\"] as const).flatMap((t) =>\n serviceContract(contract, t).map((c) => ({\n ...c,\n name: `[${t}] ${c.name}`,\n }))\n );\n }\n const { service, operation: op } = contract;\n const authHeader = (op.authHeader ?? \"AccessKey\").toLowerCase();\n const documented = new URL(op.url);\n const serverPath = trimSlash(\n new URL(op.server ?? documented.origin).pathname,\n );\n if (!documented.pathname.startsWith(serverPath)) {\n throw new Error(\n `operation.url ${op.url} does not start with server ${op.server}`,\n );\n }\n /** The operation's own path, i.e. the documented path minus the server's path. */\n const operationPath = documented.pathname.slice(serverPath.length);\n\n const withTransport = async <A>(\n body: (t: TestTransport) => Promise<A>,\n ): Promise<A> => {\n const t = await makeTransport(transport);\n try {\n return await body(t);\n } finally {\n await t.close();\n }\n };\n\n const call = (t: TestTransport, options?: ClientOptions) =>\n Effect.runPromise(\n Effect.result(op.call).pipe(\n // The stub accepts any host, so it can exercise the `Default` layer (documented server) itself.\n Effect.provide(\n Layer.provide(\n options !== undefined || t.baseUrl !== undefined\n ? service.layer({ baseUrl: t.baseUrl, ...options })\n : service.Default,\n Layer.merge(t.layer, BunnyCredentials.layer(KEY)),\n ),\n ),\n ),\n );\n\n const expectedUrl = (t: TestTransport): URL => {\n if (!t.baseUrl) return documented;\n const u = new URL(t.baseUrl);\n u.pathname = operationPath;\n u.search = documented.search;\n return u;\n };\n\n return [\n {\n name:\n \"sends the documented method, path and query, authenticating with the API-key header only\",\n run: () =>\n withTransport(async (t) => {\n t.reply({ status: op.success.status, body: op.success.body });\n await call(t);\n assert.equal(\n t.requests.length,\n 1,\n `expected one request, saw ${t.requests.length}`,\n );\n const req = t.requests[0]!;\n const actual = new URL(req.url);\n const expected = expectedUrl(t);\n assert.equal(req.method, op.method, \"method\");\n assert.equal(actual.host, expected.host, \"host\");\n assert.equal(actual.pathname, expected.pathname, \"path\");\n assertSameJson(sortedQuery(actual), sortedQuery(expected), \"query\");\n assert.equal(\n req.headers[authHeader],\n KEY,\n `${authHeader} header must carry the key`,\n );\n assert.equal(\n req.headers[\"authorization\"],\n undefined,\n \"no Authorization header is sent for an API key\",\n );\n }),\n },\n {\n name: \"decodes a documented success body into { status, value }\",\n run: () =>\n withTransport(async (t) => {\n t.reply({ status: op.success.status, body: op.success.body });\n const result = await call(t);\n assert.ok(\n Result.isSuccess(result),\n `expected Success, got ${JSON.stringify(result)}`,\n );\n assert.equal(result.success.status, op.success.status, \"status\");\n assertSameJson(\n result.success.value,\n op.success.value ?? op.success.body,\n \"decoded value\",\n );\n }),\n },\n // Only when the contract names one: a case that returns early would\n // show as green without having run.\n ...(op.error === undefined ? [] : [{\n name:\n \"fails with a typed BunnyResponseError on a documented error status\",\n run: () =>\n withTransport(async (t) => {\n const error = op.error!;\n t.reply({ status: error.status, body: error.body });\n const result = await call(t);\n assert.ok(Result.isFailure(result), \"expected Failure\");\n const err = result.failure;\n assert.ok(\n err instanceof BunnyResponseError,\n `expected BunnyResponseError, got ${String(err)}`,\n );\n assert.equal(\n err.response.status,\n error.status,\n \"error carries the status\",\n );\n assertRedacted(err);\n }),\n }]),\n {\n name: \"fails with BunnyUnexpectedStatusError on an undocumented status\",\n run: () =>\n withTransport(async (t) => {\n t.reply({ status: 418, body: \"short and stout\" });\n const result = await call(t);\n assert.ok(Result.isFailure(result), \"expected Failure\");\n const err = result.failure;\n assert.ok(\n err instanceof BunnyUnexpectedStatusError,\n `expected BunnyUnexpectedStatusError, got ${String(err)}`,\n );\n assert.equal(err.status, 418, \"error carries the status\");\n assertRedacted(err);\n }),\n },\n {\n name:\n \"surfaces a transport failure as an HttpClientError without revealing the credential\",\n run: () =>\n withTransport(async (t) => {\n t.failTransport();\n const result = await call(t);\n assert.ok(Result.isFailure(result), \"expected Failure\");\n const err = result.failure;\n assert.ok(\n HttpClientError.isHttpClientError(err),\n `expected HttpClientError, got ${String(err)}`,\n );\n assertRedacted(err);\n }),\n },\n {\n name:\n \"ClientOptions.baseUrl replaces the documented server, keeping the path\",\n run: () =>\n withTransport(async (t) => {\n const baseUrl = t.baseUrl\n ? `${t.baseUrl}/override/`\n : \"https://override.test/api/\";\n t.reply({ status: op.success.status, body: op.success.body });\n await call(t, { baseUrl });\n const actual = new URL(t.requests[0]!.url);\n assert.equal(\n actual.pathname,\n `${trimSlash(new URL(baseUrl).pathname)}${operationPath}`,\n \"path\",\n );\n assert.equal(\n actual.host,\n new URL(baseUrl).host,\n \"host follows the override\",\n );\n }),\n },\n ];\n};\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bunny.net/api-client-effect-v4-runtime",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Shared runtime for the Bunny.net Effect 4 API clients: the BunnyCredentials service, the error taxonomy, and the operation executor over Effect's HttpClient (effect/http).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/BunnyWay/bunnynet-typescript.git",
|
|
10
|
+
"directory": "packages/api-client-effect-v4-runtime"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"bunny",
|
|
14
|
+
"bunny.net",
|
|
15
|
+
"api-client",
|
|
16
|
+
"effect",
|
|
17
|
+
"effect-ts",
|
|
18
|
+
"http-client"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"!dist/tsconfig.tsbuildinfo"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/mod.d.ts",
|
|
29
|
+
"default": "./dist/mod.js"
|
|
30
|
+
},
|
|
31
|
+
"./testing": {
|
|
32
|
+
"types": "./dist/src/testing.d.ts",
|
|
33
|
+
"default": "./dist/src/testing.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"effect": "^4.1.4"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"effect": "^4.1.4"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -b",
|
|
44
|
+
"clean": "rm -rf dist"
|
|
45
|
+
}
|
|
6
46
|
}
|