@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.
@@ -0,0 +1,302 @@
1
+ import { Effect, Redacted, Schema } from "effect";
2
+ import { Headers as HttpHeaders, HttpBody, HttpClientError, HttpClientRequest, HttpClientResponse, UrlParams, } from "effect/http";
3
+ import { BunnyDecodeError, BunnyInputError, BunnyResponseError, BunnyUnexpectedStatusError, } from "./errors.js";
4
+ const trimSlash = (value) => value.replace(/\/+$/, "");
5
+ const resolvePath = (template, params) => template.replace(/\{([^}]+)\}/g, (_match, key) => {
6
+ const raw = params?.[key];
7
+ if (raw === undefined || raw === null) {
8
+ throw new Error(`Missing path param: ${key}`);
9
+ }
10
+ return encodeURIComponent(String(raw));
11
+ });
12
+ const toParamPairs = (record) => {
13
+ const pairs = [];
14
+ for (const [key, value] of Object.entries(record ?? {})) {
15
+ if (value === undefined)
16
+ continue;
17
+ if (Array.isArray(value)) {
18
+ for (const item of value) {
19
+ if (item !== undefined)
20
+ pairs.push([key, String(item)]);
21
+ }
22
+ continue;
23
+ }
24
+ pairs.push([key, String(value)]);
25
+ }
26
+ return pairs;
27
+ };
28
+ const appendFormValue = (form, key, value) => {
29
+ if (value === undefined)
30
+ return;
31
+ if (value === null) {
32
+ form.append(key, "");
33
+ }
34
+ else if (typeof Blob !== "undefined" && value instanceof Blob) {
35
+ form.append(key, value);
36
+ }
37
+ else if (typeof value === "object") {
38
+ form.append(key, JSON.stringify(value));
39
+ }
40
+ else {
41
+ form.append(key, String(value));
42
+ }
43
+ };
44
+ const encodeFormData = (body) => {
45
+ if (body instanceof FormData)
46
+ return body;
47
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
48
+ throw new Error("multipart/form-data body must be an object");
49
+ }
50
+ const form = new FormData();
51
+ for (const [key, value] of Object.entries(body)) {
52
+ if (Array.isArray(value)) {
53
+ for (const item of value)
54
+ appendFormValue(form, key, item);
55
+ }
56
+ else {
57
+ appendFormValue(form, key, value);
58
+ }
59
+ }
60
+ return form;
61
+ };
62
+ const encodeBody = (body, contentType) => {
63
+ const normalized = (contentType ?? "application/json").toLowerCase();
64
+ if (normalized.includes("multipart/form-data")) {
65
+ return HttpBody.formData(encodeFormData(body));
66
+ }
67
+ if (normalized.startsWith("text/")) {
68
+ return HttpBody.text(String(body), contentType);
69
+ }
70
+ if (normalized.includes("application/octet-stream")) {
71
+ if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
72
+ return HttpBody.uint8Array(body instanceof ArrayBuffer ? new Uint8Array(body) : body, contentType);
73
+ }
74
+ // A string body is the file's text; JSON-encoding it would send quotes.
75
+ if (typeof body === "string")
76
+ return HttpBody.text(body, contentType);
77
+ }
78
+ // JSON and JSON-flavoured media types (application/merge-patch+json, …).
79
+ return HttpBody.text(JSON.stringify(body), contentType ?? "application/json");
80
+ };
81
+ /**
82
+ * The ONE place the credential is revealed: setting the auth header /
83
+ * query param on the outgoing request.
84
+ */
85
+ const applySecurity = (request, scheme, credentials) => {
86
+ if (!scheme)
87
+ return request;
88
+ const secret = Redacted.value(credentials.accessKey);
89
+ switch (scheme.type) {
90
+ case "apiKey":
91
+ switch (scheme.in) {
92
+ case "query":
93
+ // Passed as the `Redacted` itself: Effect 4 sends it unwrapped and
94
+ // keeps it redacted in inspection, errors and the client's span.
95
+ return HttpClientRequest.appendUrlParam(request, scheme.name, credentials.accessKey);
96
+ case "cookie":
97
+ return HttpClientRequest.setHeader(request, "cookie", `${scheme.name}=${encodeURIComponent(secret)}`);
98
+ default:
99
+ return HttpClientRequest.setHeader(request, scheme.name, secret);
100
+ }
101
+ case "http":
102
+ // `basic` takes the credential as `user:password`.
103
+ return scheme.scheme === "basic"
104
+ ? HttpClientRequest.setHeader(request, "authorization", `Basic ${base64(secret)}`)
105
+ : HttpClientRequest.bearerToken(request, secret);
106
+ default:
107
+ // oauth2 / openIdConnect: the credential is an already-issued token.
108
+ return HttpClientRequest.bearerToken(request, secret);
109
+ }
110
+ };
111
+ const REDACTED = "<redacted>";
112
+ /** UTF-8 then base64, with web APIs only (`btoa` alone is Latin-1). */
113
+ const base64 = (text) => btoa(Array.from(new TextEncoder().encode(text), (b) => String.fromCharCode(b))
114
+ .join(""));
115
+ /** The same request with the credential replaced, for errors that carry the request. */
116
+ const redactRequest = (request, scheme) => {
117
+ if (!scheme)
118
+ return request;
119
+ if (scheme.type === "apiKey") {
120
+ switch (scheme.in) {
121
+ case "query":
122
+ return HttpClientRequest.setUrlParam(request, scheme.name, REDACTED);
123
+ case "cookie":
124
+ return HttpClientRequest.setHeader(request, "cookie", REDACTED);
125
+ default:
126
+ return HttpClientRequest.setHeader(request, scheme.name, REDACTED);
127
+ }
128
+ }
129
+ return HttpClientRequest.setHeader(request, "authorization", REDACTED);
130
+ };
131
+ /** The failed response with its body gone: only the status and headers survive, on a fresh `Response` shell. */
132
+ const shellResponse = (request, response) => {
133
+ const headers = new Headers();
134
+ for (const [key, value] of Object.entries(response.headers)) {
135
+ headers.set(key, value);
136
+ }
137
+ const status = response.status >= 200 && response.status <= 599
138
+ ? response.status
139
+ : 500;
140
+ return HttpClientResponse.fromWeb(request, new Response(null, { status, headers }));
141
+ };
142
+ /**
143
+ * `effect/http` errors embed the outgoing request on their `reason`. Effect's
144
+ * own redaction is render-context dependent (`UrlParams.CurrentRedactedNames`
145
+ * and the header redaction only apply inside Effect's own logger context, and
146
+ * cover a fixed list rather than Bunny's `AccessKey`), so a plain
147
+ * `JSON.stringify` in user code would still print the key. Rebuilding the
148
+ * error around a reason that carries the scrubbed request is the one layer
149
+ * that makes "no rendering can reveal it" true. `request` and `response` on
150
+ * the `HttpClientError` wrapper are getters over the reason, so rebuilding the
151
+ * reason is enough. The body of a failed response has already been consumed
152
+ * by the time a response reason is produced here, so the shell response keeps
153
+ * only its status and headers.
154
+ */
155
+ const redactHttpClientError = (scheme) => (error) => {
156
+ const e = error;
157
+ if (!HttpClientError.isHttpClientError(e))
158
+ return error;
159
+ const reason = e.reason;
160
+ const request = redactRequest(reason.request, scheme);
161
+ const common = {
162
+ request,
163
+ cause: reason.cause,
164
+ ...(reason.description === undefined
165
+ ? {}
166
+ : { description: reason.description }),
167
+ };
168
+ const rebuilt = (() => {
169
+ switch (reason._tag) {
170
+ case "TransportError":
171
+ return new HttpClientError.TransportError(common);
172
+ case "EncodeError":
173
+ return new HttpClientError.EncodeError(common);
174
+ case "InvalidUrlError":
175
+ return new HttpClientError.InvalidUrlError(common);
176
+ case "StatusCodeError":
177
+ return new HttpClientError.StatusCodeError({
178
+ ...common,
179
+ response: shellResponse(request, reason.response),
180
+ });
181
+ case "DecodeError":
182
+ return new HttpClientError.DecodeError({
183
+ ...common,
184
+ response: shellResponse(request, reason.response),
185
+ });
186
+ case "EmptyBodyError":
187
+ return new HttpClientError.EmptyBodyError({
188
+ ...common,
189
+ response: shellResponse(request, reason.response),
190
+ });
191
+ }
192
+ })();
193
+ return new HttpClientError.HttpClientError({ reason: rebuilt });
194
+ };
195
+ /**
196
+ * The names the credential travels under, added to Effect's own redaction
197
+ * lists for the duration of the call: the HTTP client's span records request
198
+ * headers only when a consumer opts in (`TracerHeaderFilter`) and URL
199
+ * parameters always, and its default lists know `authorization` and `cookie`
200
+ * but not Bunny's `AccessKey`. Defence in depth beside the `Redacted` value
201
+ * itself; the `bunny.<op>` span never carries the input.
202
+ */
203
+ const withCredentialRedacted = (scheme, effect) => {
204
+ if (scheme?.type !== "apiKey")
205
+ return effect;
206
+ const name = scheme.in === "cookie" ? "cookie" : scheme.name;
207
+ const ref = scheme.in === "query"
208
+ ? UrlParams.CurrentRedactedNames
209
+ : HttpHeaders.CurrentRedactedNames;
210
+ return Effect.flatMap(ref, (names) => Effect.provideService(ref, [...names, name])(effect));
211
+ };
212
+ const buildRequest = (ctx, op, decoded) => {
213
+ const baseUrl = ctx.options.baseUrl ?? op.baseUrl;
214
+ if (!baseUrl) {
215
+ throw new Error(`${op.name}: no baseUrl — the spec has no server URL, pass one in ClientOptions`);
216
+ }
217
+ const path = resolvePath(op.path, decoded.path);
218
+ let request = HttpClientRequest.make(op.method)(`${trimSlash(baseUrl)}${path.startsWith("/") ? "" : "/"}${path}`);
219
+ request = HttpClientRequest.appendUrlParams(request, toParamPairs(decoded.query));
220
+ for (const [key, value] of toParamPairs(decoded.headers)) {
221
+ request = HttpClientRequest.setHeader(request, key, value);
222
+ }
223
+ if (decoded.body !== undefined) {
224
+ request = HttpClientRequest.setBody(request, encodeBody(decoded.body, op.contentType));
225
+ }
226
+ return applySecurity(request, op.security, ctx.credentials);
227
+ };
228
+ // ---------------------------------------------------------------------------
229
+ // Response reading
230
+ // ---------------------------------------------------------------------------
231
+ const readRaw = (response, kind) => {
232
+ switch (kind) {
233
+ case "json":
234
+ return response.json;
235
+ case "text":
236
+ return response.text;
237
+ case "binary":
238
+ case "stream":
239
+ return response.arrayBuffer;
240
+ case "empty":
241
+ return Effect.void;
242
+ }
243
+ };
244
+ const tryParseJson = (text) => {
245
+ if (text === undefined || text === "")
246
+ return undefined;
247
+ try {
248
+ return JSON.parse(text);
249
+ }
250
+ catch {
251
+ return text;
252
+ }
253
+ };
254
+ const lookup = (spec, status) => spec === undefined ? undefined : (spec[String(status)] ?? spec["default"]);
255
+ // ---------------------------------------------------------------------------
256
+ // The executor
257
+ // ---------------------------------------------------------------------------
258
+ /**
259
+ * Run one operation: decode the input, build the request, send it through
260
+ * the caller's `HttpClient`, and decode the response by status. Resolves
261
+ * with `{ status, value }` for documented success statuses, fails with a
262
+ * typed `BunnyResponseError` for documented error statuses.
263
+ */
264
+ export const executeOperation = (ctx, op, input) => Effect.gen(function* () {
265
+ // ENCODE, not decode: callers pass the schema's Type (Date, branded ids, …)
266
+ // and the wire needs the Encoded form (ISO strings, …). Encoding also
267
+ // validates the input against the Type side.
268
+ const decoded = op.input === undefined
269
+ ? {}
270
+ : yield* Schema.encodeUnknownEffect(op.input)(input).pipe(Effect.mapError((cause) => new BunnyInputError({ operation: op.name, cause })));
271
+ const request = buildRequest(ctx, op, decoded);
272
+ const response = yield* withCredentialRedacted(op.security, ctx.http.execute(request));
273
+ const status = response.status;
274
+ const successEntry = lookup(op.success, status);
275
+ const errorEntry = lookup(op.error, status);
276
+ const entry = successEntry ?? errorEntry;
277
+ if (entry === undefined) {
278
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => undefined));
279
+ return yield* new BunnyUnexpectedStatusError({
280
+ operation: op.name,
281
+ status,
282
+ body: tryParseJson(text),
283
+ });
284
+ }
285
+ const raw = yield* readRaw(response, entry.kind);
286
+ const value = entry.kind === "binary" || entry.kind === "stream"
287
+ ? raw
288
+ : yield* Schema.decodeUnknownEffect(entry.schema)(raw).pipe(Effect.mapError((cause) => new BunnyDecodeError({ operation: op.name, status, cause })));
289
+ const result = { status, value };
290
+ if (successEntry !== undefined)
291
+ return result;
292
+ return yield* new BunnyResponseError({
293
+ operation: op.name,
294
+ response: result,
295
+ });
296
+ }).pipe(Effect.mapError(redactHttpClientError(op.security)),
297
+ // Kept for symmetry with the Effect 3 runtime: in Effect 4 `execute` adds
298
+ // no `Scope` to `R`, so this is a no-op on the requirements.
299
+ Effect.scoped, Effect.withSpan(`bunny.${op.name}`, {
300
+ attributes: { "http.request.method": op.method, "url.template": op.path },
301
+ }));
302
+ //# sourceMappingURL=operation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operation.js","sourceRoot":"","sources":["../../src/operation.ts"],"names":[],"mappings":"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","sourcesContent":["import { Effect, Redacted, Schema } from \"effect\";\nimport {\n Headers as HttpHeaders,\n HttpBody,\n type HttpClient,\n HttpClientError,\n HttpClientRequest,\n HttpClientResponse,\n UrlParams,\n} from \"effect/http\";\nimport type { BunnyCredentialsShape } from \"./credentials.ts\";\nimport {\n type BunnyClientError,\n BunnyDecodeError,\n BunnyInputError,\n BunnyResponseError,\n BunnyUnexpectedStatusError,\n} from \"./errors.ts\";\n\n// ---------------------------------------------------------------------------\n// Response typing — shared by every generated client.\n// ---------------------------------------------------------------------------\n\n/** How a response body is read before (or instead of) schema decoding. `stream` is read fully, like `binary`. */\nexport type ResponseKind = \"json\" | \"text\" | \"empty\" | \"binary\" | \"stream\";\n\n/**\n * Any schema with no decoding and no encoding services: `Codec` is covariant\n * in its type parameters, so every concrete schema is assignable here, and\n * the `never` services pin every operation to `R = never`, whatever schema the\n * generator emits (`Schema.Top` would widen the services to `unknown`).\n */\nexport type ServiceFreeCodec = Schema.Codec<unknown, unknown, never, never>;\n\nexport interface ResponseEntry {\n readonly schema: ServiceFreeCodec;\n readonly kind: ResponseKind;\n}\n\n/** Status code (or `\"default\"`) → how to read + decode that response. */\nexport type ResponseSpec = Record<string, ResponseEntry>;\n\nexport type ResponseValue<T extends ResponseEntry> = T[\"kind\"] extends\n \"stream\" | \"binary\" ? ArrayBuffer\n : T[\"schema\"][\"Type\"];\n\n/** The `{ status, value }` union an operation resolves (or fails) with, one member per documented status. */\nexport type ResponseUnion<T extends ResponseSpec> = {\n [K in keyof T]: K extends \"default\"\n ? { readonly status: number; readonly value: ResponseValue<T[K]> }\n : K extends `${infer N extends number}`\n ? { readonly status: N; readonly value: ResponseValue<T[K]> }\n : K extends number\n ? { readonly status: K; readonly value: ResponseValue<T[K]> }\n : { readonly status: number; readonly value: ResponseValue<T[K]> };\n}[keyof T];\n\n/** The typed failure for an operation's documented error statuses (`never` when the spec documents none). */\nexport type OperationFailure<E extends ResponseSpec | undefined> = E extends\n ResponseSpec ? BunnyResponseError<ResponseUnion<E>>\n : never;\n\n// ---------------------------------------------------------------------------\n// Operation descriptors — what the generator emits per operation.\n// ---------------------------------------------------------------------------\n\nexport type SecurityScheme =\n | {\n readonly type: \"http\";\n readonly scheme: \"bearer\" | \"basic\";\n readonly description?: string;\n }\n | {\n readonly type: \"apiKey\";\n readonly in: \"header\" | \"query\" | \"cookie\";\n readonly name: string;\n readonly description?: string;\n }\n | {\n readonly type: \"oauth2\";\n readonly flows: unknown;\n readonly description?: string;\n }\n | {\n readonly type: \"openIdConnect\";\n readonly openIdConnectUrl: string;\n readonly description?: string;\n };\n\nexport type HttpMethod =\n | \"GET\"\n | \"POST\"\n | \"PUT\"\n | \"PATCH\"\n | \"DELETE\"\n | \"HEAD\"\n | \"OPTIONS\";\n\nexport interface OperationDescriptor<\n S extends ResponseSpec,\n E extends ResponseSpec | undefined = undefined,\n> {\n /** Operation id, used in error messages and the tracing span name. */\n readonly name: string;\n readonly method: HttpMethod;\n /** Path template with `{param}` placeholders. */\n readonly path: string;\n /** The spec's server URL for this operation; `ClientOptions.baseUrl` overrides it. */\n readonly baseUrl?: string;\n /** How the credential is sent. Omit for unauthenticated operations. */\n readonly security?: SecurityScheme;\n /** `{ path?, query?, headers?, body? }` schema; omit when the operation takes no input. Its encoded side is checked against `DecodedInput`. */\n readonly input?: Schema.Codec<unknown, DecodedInput, never, never>;\n /** Request body content type; defaults to JSON. */\n readonly contentType?: string;\n readonly success: S;\n readonly error?: E;\n}\n\n/** Per-client options every generated `layer(options)` accepts. */\nexport interface ClientOptions {\n /** Overrides the spec's server URL — e.g. to point a client at a staging host. */\n readonly baseUrl?: string;\n}\n\n/** What a generated service captures once at layer construction. */\nexport interface ClientContext {\n readonly http: HttpClient.HttpClient;\n readonly credentials: BunnyCredentialsShape;\n readonly options: ClientOptions;\n}\n\n// ---------------------------------------------------------------------------\n// Request building\n// ---------------------------------------------------------------------------\n\n/** The encoded form of an operation's input: what the generated input schema must encode to. */\nexport type DecodedInput = {\n readonly path?: Record<string, unknown>;\n readonly query?: Record<string, unknown>;\n readonly headers?: Record<string, unknown>;\n readonly body?: unknown;\n};\n\nconst trimSlash = (value: string): string => value.replace(/\\/+$/, \"\");\n\nconst resolvePath = (\n template: string,\n params: Record<string, unknown> | undefined,\n): string =>\n template.replace(/\\{([^}]+)\\}/g, (_match, key: string) => {\n const raw = params?.[key];\n if (raw === undefined || raw === null) {\n throw new Error(`Missing path param: ${key}`);\n }\n return encodeURIComponent(String(raw));\n });\n\nconst toParamPairs = (\n record: Record<string, unknown> | undefined,\n): Array<[string, string]> => {\n const pairs: Array<[string, string]> = [];\n for (const [key, value] of Object.entries(record ?? {})) {\n if (value === undefined) continue;\n if (Array.isArray(value)) {\n for (const item of value) {\n if (item !== undefined) pairs.push([key, String(item)]);\n }\n continue;\n }\n pairs.push([key, String(value)]);\n }\n return pairs;\n};\n\nconst appendFormValue = (form: FormData, key: string, value: unknown): void => {\n if (value === undefined) return;\n if (value === null) {\n form.append(key, \"\");\n } else if (typeof Blob !== \"undefined\" && value instanceof Blob) {\n form.append(key, value);\n } else if (typeof value === \"object\") {\n form.append(key, JSON.stringify(value));\n } else {\n form.append(key, String(value));\n }\n};\n\nconst encodeFormData = (body: unknown): FormData => {\n if (body instanceof FormData) return body;\n if (!body || typeof body !== \"object\" || Array.isArray(body)) {\n throw new Error(\"multipart/form-data body must be an object\");\n }\n const form = new FormData();\n for (const [key, value] of Object.entries(body as Record<string, unknown>)) {\n if (Array.isArray(value)) {\n for (const item of value) appendFormValue(form, key, item);\n } else {\n appendFormValue(form, key, value);\n }\n }\n return form;\n};\n\nconst encodeBody = (\n body: unknown,\n contentType: string | undefined,\n): HttpBody.HttpBody => {\n const normalized = (contentType ?? \"application/json\").toLowerCase();\n if (normalized.includes(\"multipart/form-data\")) {\n return HttpBody.formData(encodeFormData(body));\n }\n if (normalized.startsWith(\"text/\")) {\n return HttpBody.text(String(body), contentType);\n }\n if (normalized.includes(\"application/octet-stream\")) {\n if (body instanceof Uint8Array || body instanceof ArrayBuffer) {\n return HttpBody.uint8Array(\n body instanceof ArrayBuffer ? new Uint8Array(body) : body,\n contentType,\n );\n }\n // A string body is the file's text; JSON-encoding it would send quotes.\n if (typeof body === \"string\") return HttpBody.text(body, contentType);\n }\n // JSON and JSON-flavoured media types (application/merge-patch+json, …).\n return HttpBody.text(JSON.stringify(body), contentType ?? \"application/json\");\n};\n\n/**\n * The ONE place the credential is revealed: setting the auth header /\n * query param on the outgoing request.\n */\nconst applySecurity = (\n request: HttpClientRequest.HttpClientRequest,\n scheme: SecurityScheme | undefined,\n credentials: BunnyCredentialsShape,\n): HttpClientRequest.HttpClientRequest => {\n if (!scheme) return request;\n const secret = Redacted.value(credentials.accessKey);\n switch (scheme.type) {\n case \"apiKey\":\n switch (scheme.in) {\n case \"query\":\n // Passed as the `Redacted` itself: Effect 4 sends it unwrapped and\n // keeps it redacted in inspection, errors and the client's span.\n return HttpClientRequest.appendUrlParam(\n request,\n scheme.name,\n credentials.accessKey,\n );\n case \"cookie\":\n return HttpClientRequest.setHeader(\n request,\n \"cookie\",\n `${scheme.name}=${encodeURIComponent(secret)}`,\n );\n default:\n return HttpClientRequest.setHeader(request, scheme.name, secret);\n }\n case \"http\":\n // `basic` takes the credential as `user:password`.\n return scheme.scheme === \"basic\"\n ? HttpClientRequest.setHeader(\n request,\n \"authorization\",\n `Basic ${base64(secret)}`,\n )\n : HttpClientRequest.bearerToken(request, secret);\n default:\n // oauth2 / openIdConnect: the credential is an already-issued token.\n return HttpClientRequest.bearerToken(request, secret);\n }\n};\n\nconst REDACTED = \"<redacted>\";\n\n/** UTF-8 then base64, with web APIs only (`btoa` alone is Latin-1). */\nconst base64 = (text: string): string =>\n btoa(\n Array.from(new TextEncoder().encode(text), (b) => String.fromCharCode(b))\n .join(\"\"),\n );\n\n/** The same request with the credential replaced, for errors that carry the request. */\nconst redactRequest = (\n request: HttpClientRequest.HttpClientRequest,\n scheme: SecurityScheme | undefined,\n): HttpClientRequest.HttpClientRequest => {\n if (!scheme) return request;\n if (scheme.type === \"apiKey\") {\n switch (scheme.in) {\n case \"query\":\n return HttpClientRequest.setUrlParam(request, scheme.name, REDACTED);\n case \"cookie\":\n return HttpClientRequest.setHeader(request, \"cookie\", REDACTED);\n default:\n return HttpClientRequest.setHeader(request, scheme.name, REDACTED);\n }\n }\n return HttpClientRequest.setHeader(request, \"authorization\", REDACTED);\n};\n\n/** The failed response with its body gone: only the status and headers survive, on a fresh `Response` shell. */\nconst shellResponse = (\n request: HttpClientRequest.HttpClientRequest,\n response: HttpClientResponse.HttpClientResponse,\n): HttpClientResponse.HttpClientResponse => {\n const headers = new Headers();\n for (const [key, value] of Object.entries(response.headers)) {\n headers.set(key, value);\n }\n const status = response.status >= 200 && response.status <= 599\n ? response.status\n : 500;\n return HttpClientResponse.fromWeb(\n request,\n new Response(null, { status, headers }),\n );\n};\n\n/**\n * `effect/http` errors embed the outgoing request on their `reason`. Effect's\n * own redaction is render-context dependent (`UrlParams.CurrentRedactedNames`\n * and the header redaction only apply inside Effect's own logger context, and\n * cover a fixed list rather than Bunny's `AccessKey`), so a plain\n * `JSON.stringify` in user code would still print the key. Rebuilding the\n * error around a reason that carries the scrubbed request is the one layer\n * that makes \"no rendering can reveal it\" true. `request` and `response` on\n * the `HttpClientError` wrapper are getters over the reason, so rebuilding the\n * reason is enough. The body of a failed response has already been consumed\n * by the time a response reason is produced here, so the shell response keeps\n * only its status and headers.\n */\nconst redactHttpClientError =\n (scheme: SecurityScheme | undefined) => <X>(error: X): X => {\n const e: unknown = error;\n if (!HttpClientError.isHttpClientError(e)) return error;\n const reason = e.reason;\n const request = redactRequest(reason.request, scheme);\n const common = {\n request,\n cause: reason.cause,\n ...(reason.description === undefined\n ? {}\n : { description: reason.description }),\n };\n const rebuilt: HttpClientError.HttpClientErrorReason = (() => {\n switch (reason._tag) {\n case \"TransportError\":\n return new HttpClientError.TransportError(common);\n case \"EncodeError\":\n return new HttpClientError.EncodeError(common);\n case \"InvalidUrlError\":\n return new HttpClientError.InvalidUrlError(common);\n case \"StatusCodeError\":\n return new HttpClientError.StatusCodeError({\n ...common,\n response: shellResponse(request, reason.response),\n });\n case \"DecodeError\":\n return new HttpClientError.DecodeError({\n ...common,\n response: shellResponse(request, reason.response),\n });\n case \"EmptyBodyError\":\n return new HttpClientError.EmptyBodyError({\n ...common,\n response: shellResponse(request, reason.response),\n });\n }\n })();\n return new HttpClientError.HttpClientError({ reason: rebuilt }) as X;\n };\n\n/**\n * The names the credential travels under, added to Effect's own redaction\n * lists for the duration of the call: the HTTP client's span records request\n * headers only when a consumer opts in (`TracerHeaderFilter`) and URL\n * parameters always, and its default lists know `authorization` and `cookie`\n * but not Bunny's `AccessKey`. Defence in depth beside the `Redacted` value\n * itself; the `bunny.<op>` span never carries the input.\n */\nconst withCredentialRedacted = <A, E, R>(\n scheme: SecurityScheme | undefined,\n effect: Effect.Effect<A, E, R>,\n): Effect.Effect<A, E, R> => {\n if (scheme?.type !== \"apiKey\") return effect;\n const name = scheme.in === \"cookie\" ? \"cookie\" : scheme.name;\n const ref = scheme.in === \"query\"\n ? UrlParams.CurrentRedactedNames\n : HttpHeaders.CurrentRedactedNames;\n return Effect.flatMap(\n ref,\n (names) => Effect.provideService(ref, [...names, name])(effect),\n );\n};\n\nconst buildRequest = <\n S extends ResponseSpec,\n E extends ResponseSpec | undefined,\n>(\n ctx: ClientContext,\n op: OperationDescriptor<S, E>,\n decoded: DecodedInput,\n): HttpClientRequest.HttpClientRequest => {\n const baseUrl = ctx.options.baseUrl ?? op.baseUrl;\n if (!baseUrl) {\n throw new Error(\n `${op.name}: no baseUrl — the spec has no server URL, pass one in ClientOptions`,\n );\n }\n const path = resolvePath(op.path, decoded.path);\n let request = HttpClientRequest.make(op.method)(\n `${trimSlash(baseUrl)}${path.startsWith(\"/\") ? \"\" : \"/\"}${path}`,\n );\n request = HttpClientRequest.appendUrlParams(\n request,\n toParamPairs(decoded.query),\n );\n for (const [key, value] of toParamPairs(decoded.headers)) {\n request = HttpClientRequest.setHeader(request, key, value);\n }\n if (decoded.body !== undefined) {\n request = HttpClientRequest.setBody(\n request,\n encodeBody(decoded.body, op.contentType),\n );\n }\n return applySecurity(request, op.security, ctx.credentials);\n};\n\n// ---------------------------------------------------------------------------\n// Response reading\n// ---------------------------------------------------------------------------\n\nconst readRaw = (\n response: HttpClientResponse.HttpClientResponse,\n kind: ResponseKind,\n) => {\n switch (kind) {\n case \"json\":\n return response.json;\n case \"text\":\n return response.text;\n case \"binary\":\n case \"stream\":\n return response.arrayBuffer;\n case \"empty\":\n return Effect.void;\n }\n};\n\nconst tryParseJson = (text: string | undefined): unknown => {\n if (text === undefined || text === \"\") return undefined;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n};\n\nconst lookup = (\n spec: ResponseSpec | undefined,\n status: number,\n): ResponseEntry | undefined =>\n spec === undefined ? undefined : (spec[String(status)] ?? spec[\"default\"]);\n\n// ---------------------------------------------------------------------------\n// The executor\n// ---------------------------------------------------------------------------\n\n/**\n * Run one operation: decode the input, build the request, send it through\n * the caller's `HttpClient`, and decode the response by status. Resolves\n * with `{ status, value }` for documented success statuses, fails with a\n * typed `BunnyResponseError` for documented error statuses.\n */\nexport const executeOperation = <\n S extends ResponseSpec,\n E extends ResponseSpec | undefined,\n>(\n ctx: ClientContext,\n op: OperationDescriptor<S, E>,\n input: unknown,\n): Effect.Effect<\n ResponseUnion<S>,\n BunnyClientError | OperationFailure<E>,\n never\n> =>\n Effect.gen(function* () {\n // ENCODE, not decode: callers pass the schema's Type (Date, branded ids, …)\n // and the wire needs the Encoded form (ISO strings, …). Encoding also\n // validates the input against the Type side.\n const decoded: DecodedInput = op.input === undefined\n ? {}\n : yield* Schema.encodeUnknownEffect(op.input)(input).pipe(\n Effect.mapError((cause) =>\n new BunnyInputError({ operation: op.name, cause })\n ),\n );\n const request = buildRequest(ctx, op, decoded);\n const response = yield* withCredentialRedacted(\n op.security,\n ctx.http.execute(request),\n );\n const status = response.status;\n const successEntry = lookup(op.success, status);\n const errorEntry = lookup(op.error, status);\n const entry = successEntry ?? errorEntry;\n if (entry === undefined) {\n const text = yield* response.text.pipe(\n Effect.orElseSucceed(() => undefined),\n );\n return yield* new BunnyUnexpectedStatusError({\n operation: op.name,\n status,\n body: tryParseJson(text),\n });\n }\n const raw = yield* readRaw(response, entry.kind);\n const value = entry.kind === \"binary\" || entry.kind === \"stream\"\n ? raw\n : yield* Schema.decodeUnknownEffect(entry.schema)(raw).pipe(\n Effect.mapError((cause) =>\n new BunnyDecodeError({ operation: op.name, status, cause })\n ),\n );\n const result = { status, value };\n if (successEntry !== undefined) return result as ResponseUnion<S>;\n return yield* new BunnyResponseError({\n operation: op.name,\n response: result,\n }) as OperationFailure<E>;\n }).pipe(\n Effect.mapError(redactHttpClientError(op.security)),\n // Kept for symmetry with the Effect 3 runtime: in Effect 4 `execute` adds\n // no `Scope` to `R`, so this is a no-op on the requirements.\n Effect.scoped,\n Effect.withSpan(`bunny.${op.name}`, {\n attributes: { \"http.request.method\": op.method, \"url.template\": op.path },\n }),\n );\n"]}
@@ -0,0 +1,78 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { HttpClient } from "effect/http";
3
+ import { BunnyCredentials } from "./credentials.js";
4
+ import type { ClientOptions } from "./operation.js";
5
+ export interface RecordedRequest {
6
+ readonly method: string;
7
+ /** Absolute URL including the query string. */
8
+ readonly url: string;
9
+ /** Header names lower-cased. */
10
+ readonly headers: Record<string, string>;
11
+ }
12
+ export interface Reply {
13
+ readonly status: number;
14
+ /** JSON-encoded when an object; sent verbatim when a string; nothing when undefined. */
15
+ readonly body?: unknown;
16
+ readonly headers?: Record<string, string>;
17
+ }
18
+ export interface TestTransport {
19
+ readonly layer: Layer.Layer<HttpClient.HttpClient>;
20
+ readonly requests: RecordedRequest[];
21
+ /** Queue the reply (or a function of the request) for every following request. Defaults to 200 with no body. */
22
+ reply(next: Reply | ((request: RecordedRequest) => Reply)): void;
23
+ /** Make every further request fail at the transport level (connection refused / socket error). */
24
+ failTransport(): void;
25
+ /** Base URL to point the service at; undefined for the stub (which accepts any host). */
26
+ readonly baseUrl?: string;
27
+ close(): Promise<void>;
28
+ }
29
+ /** An `HttpClient` that never touches the network. */
30
+ export declare const stubHttpClient: () => TestTransport;
31
+ /** A loopback `node:http` server driven through `effect/http`'s `FetchHttpClient`. */
32
+ export declare const fetchHttpClient: () => Promise<TestTransport>;
33
+ export type Transport = "stub" | "fetch";
34
+ export declare const makeTransport: (transport: Transport) => Promise<TestTransport>;
35
+ /** The static side every generated `<Name>` service class has. */
36
+ export interface ServiceClass<Id> {
37
+ readonly Default: Layer.Layer<Id, never, HttpClient.HttpClient | BunnyCredentials>;
38
+ readonly layer: (options: ClientOptions) => Layer.Layer<Id, never, HttpClient.HttpClient | BunnyCredentials>;
39
+ }
40
+ export interface ServiceContract<Id> {
41
+ readonly service: ServiceClass<Id>;
42
+ /** One representative read operation, with the request the spec documents for it. */
43
+ readonly operation: {
44
+ /** The call, e.g. `Effect.flatMap(BunnyCore, (core) => core.pullZonePublicIndex({ path: { id: 7 } }))`. */
45
+ readonly call: Effect.Effect<{
46
+ readonly status: number;
47
+ readonly value: unknown;
48
+ }, unknown, Id>;
49
+ readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
50
+ /** The documented absolute URL, query string included. */
51
+ readonly url: string;
52
+ /** The spec's server URL when it carries a path (e.g. `https://host/api/mc`); defaults to the URL's origin. */
53
+ readonly server?: string;
54
+ /** The spec's API-key header name; Bunny's APIs all use `AccessKey`. */
55
+ readonly authHeader?: string;
56
+ readonly success: {
57
+ readonly status: number;
58
+ readonly body?: unknown;
59
+ readonly value?: unknown;
60
+ };
61
+ /** A documented error status, when the operation has one. */
62
+ readonly error?: {
63
+ readonly status: number;
64
+ readonly body?: unknown;
65
+ };
66
+ };
67
+ }
68
+ export interface ContractCase {
69
+ readonly name: string;
70
+ readonly run: () => Promise<void>;
71
+ }
72
+ /**
73
+ * Build the contract cases for one service: over both transports when
74
+ * `transport` is omitted (case names are prefixed `[stub]` / `[fetch]`), or
75
+ * over one. Every case sets up its own transport, so cases are independent
76
+ * and can run in any order.
77
+ */
78
+ export declare const serviceContract: <Id>(contract: ServiceContract<Id>, transport?: Transport) => ContractCase[];