@bunny.net/api-client-effect-v4-runtime 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bunny.net
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <a href="https://bunny.net">
3
+ <img src="https://github.com/BunnyWay/bunnynet-typescript/blob/main/asset/bunny.png?raw=true" width="500" height="auto" alt="Bunny"/>
4
+ </a>
5
+ </div>
2
6
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
7
+ # Bunny API client runtime for Effect 4
8
+
9
+ This package contains `@bunny.net/api-client-effect-v4-runtime`, what every
10
+ `@bunny.net/api-client-effect-v4-*` client shares: the `BunnyCredentials`
11
+ service, the error types and the operation executor. You get it with any client;
12
+ import it directly for the credential layers and to match on errors. It is the
13
+ Effect 4 flavour; on Effect 3, use `@bunny.net/api-client-effect-runtime` and
14
+ the `@bunny.net/api-client-effect-*` clients instead.
15
+
16
+ ## 🥕 Usage
17
+
18
+ It comes as a peer dependency of each client, so one copy serves every Bunny
19
+ client in an app and their service tags and error classes are the same objects:
20
+
21
+ ```sh
22
+ npm install @bunny.net/api-client-effect-v4-core @bunny.net/api-client-effect-v4-runtime effect
23
+ ```
24
+
25
+ ### Handling a documented error
26
+
27
+ ```typescript
28
+ import { Effect, Layer } from "effect";
29
+ import { FetchHttpClient } from "effect/http";
30
+ import { BunnyCore } from "@bunny.net/api-client-effect-v4-core";
31
+ import { BunnyCredentials } from "@bunny.net/api-client-effect-v4-runtime";
32
+
33
+ const program = Effect.gen(function* () {
34
+ const core = yield* BunnyCore;
35
+ const zone = yield* core.pullZonePublicIndex({ path: { id: 123 } });
36
+ return zone.value.Name;
37
+ }).pipe(
38
+ Effect.catchTag(
39
+ "BunnyResponseError",
40
+ (error) =>
41
+ Effect.succeed(`pull zone 123 answered HTTP ${error.response.status}`),
42
+ ),
43
+ );
44
+
45
+ const Live = BunnyCore.Default.pipe(
46
+ Layer.provide(BunnyCredentials.layer(process.env.BUNNY_API_KEY!)),
47
+ Layer.provide(FetchHttpClient.layer),
48
+ );
49
+
50
+ console.log(await Effect.runPromise(program.pipe(Effect.provide(Live))));
51
+ ```
52
+
53
+ `BunnyCredentials.layer(key)` holds one key, as a string or an existing
54
+ `Redacted`; `BunnyCredentials.fromEnv(name)` reads it from configuration,
55
+ `BUNNY_API_KEY` by default. The key is kept `Redacted`, so it never shows up
56
+ through `toString`, `JSON.stringify` or inspection, and it is revealed at one
57
+ place only: when the auth header is set on the outgoing request.
58
+
59
+ The errors are all `Data.TaggedError`, so `Effect.catchTag` narrows them:
60
+
61
+ - `BunnyInputError`: the input was rejected by the operation's schema and
62
+ nothing was sent.
63
+ - `BunnyResponseError`: a documented error status, with `response` holding the
64
+ status and the body decoded by that status's schema.
65
+ - `BunnyDecodeError`: a documented status whose body the schema rejects. This is
66
+ the signal that the live API drifted from its spec.
67
+ - `BunnyUnexpectedStatusError`: a status the spec doesn't document, with the raw
68
+ body attached.
69
+
70
+ Transport failures are Effect's own `HttpClientError` from `effect/http`: one
71
+ class, tagged `"HttpClientError"`, whose `reason` says what happened
72
+ (`TransportError`, `StatusCodeError`, …). Coming from the Effect 3 client,
73
+ `Effect.catchTag("RequestError", …)` becomes
74
+ `Effect.catchTag("HttpClientError", …)` with a match on `error.reason._tag`.
75
+ Generated clients contain no transport code: retries, timeouts, tracing and
76
+ request rewriting are whatever your `HttpClient` layer does, and every operation
77
+ runs inside a span named after its operation id.
78
+
79
+ The `/testing` subpath has `stubHttpClient()`, `fetchHttpClient()` and
80
+ `serviceContract()`, the behaviours every generated client is checked against.
81
+
82
+ ## 🔧 Contributing
83
+
84
+ The development setup, the commands and how releases are made are described in
85
+ [docs/human_development.md](https://github.com/BunnyWay/bunnynet-typescript/blob/main/docs/human_development.md).
package/dist/mod.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @bunny.net/api-client-effect-v4-runtime
3
+ *
4
+ * The shared runtime every generated Bunny.net Effect 4 client depends on:
5
+ *
6
+ * - `BunnyCredentials` — the service that carries the API key (a
7
+ * `Redacted` string). Provide it with `BunnyCredentials.layer(key)` or
8
+ * `BunnyCredentials.fromEnv()`.
9
+ * - The error taxonomy — tagged errors on the Effect error channel
10
+ * (`BunnyInputError`, `BunnyResponseError`, `BunnyDecodeError`,
11
+ * `BunnyUnexpectedStatusError`); transport failures surface as
12
+ * Effect's own `HttpClientError` from `effect/http`.
13
+ * - `executeOperation` — runs one generated operation descriptor
14
+ * against the `HttpClient` from context. Generated clients never
15
+ * touch `fetch`: whichever `HttpClient` layer the caller provides
16
+ * (`FetchHttpClient.layer`, `NodeHttpClient.layer`, a test stub) is
17
+ * what sends the request, and retry / timeout / tracing are the
18
+ * caller's `HttpClient` combinators, not generated code.
19
+ */
20
+ export * from "./src/credentials.js";
21
+ export * from "./src/errors.js";
22
+ export * from "./src/operation.js";
package/dist/mod.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @bunny.net/api-client-effect-v4-runtime
3
+ *
4
+ * The shared runtime every generated Bunny.net Effect 4 client depends on:
5
+ *
6
+ * - `BunnyCredentials` — the service that carries the API key (a
7
+ * `Redacted` string). Provide it with `BunnyCredentials.layer(key)` or
8
+ * `BunnyCredentials.fromEnv()`.
9
+ * - The error taxonomy — tagged errors on the Effect error channel
10
+ * (`BunnyInputError`, `BunnyResponseError`, `BunnyDecodeError`,
11
+ * `BunnyUnexpectedStatusError`); transport failures surface as
12
+ * Effect's own `HttpClientError` from `effect/http`.
13
+ * - `executeOperation` — runs one generated operation descriptor
14
+ * against the `HttpClient` from context. Generated clients never
15
+ * touch `fetch`: whichever `HttpClient` layer the caller provides
16
+ * (`FetchHttpClient.layer`, `NodeHttpClient.layer`, a test stub) is
17
+ * what sends the request, and retry / timeout / tracing are the
18
+ * caller's `HttpClient` combinators, not generated code.
19
+ */
20
+ export * from "./src/credentials.js";
21
+ export * from "./src/errors.js";
22
+ export * from "./src/operation.js";
23
+ //# sourceMappingURL=mod.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mod.js","sourceRoot":"","sources":["../mod.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC","sourcesContent":["/**\n * @bunny.net/api-client-effect-v4-runtime\n *\n * The shared runtime every generated Bunny.net Effect 4 client depends on:\n *\n * - `BunnyCredentials` — the service that carries the API key (a\n * `Redacted` string). Provide it with `BunnyCredentials.layer(key)` or\n * `BunnyCredentials.fromEnv()`.\n * - The error taxonomy — tagged errors on the Effect error channel\n * (`BunnyInputError`, `BunnyResponseError`, `BunnyDecodeError`,\n * `BunnyUnexpectedStatusError`); transport failures surface as\n * Effect's own `HttpClientError` from `effect/http`.\n * - `executeOperation` — runs one generated operation descriptor\n * against the `HttpClient` from context. Generated clients never\n * touch `fetch`: whichever `HttpClient` layer the caller provides\n * (`FetchHttpClient.layer`, `NodeHttpClient.layer`, a test stub) is\n * what sends the request, and retry / timeout / tracing are the\n * caller's `HttpClient` combinators, not generated code.\n */\nexport * from \"./src/credentials.ts\";\nexport * from \"./src/errors.ts\";\nexport * from \"./src/operation.ts\";\n"]}
@@ -0,0 +1,20 @@
1
+ import { Config, Context, Layer, Redacted } from "effect";
2
+ /** What a Bunny.net client needs to authenticate: the API key (or, for the Edge Storage API, the zone password). */
3
+ export interface BunnyCredentialsShape {
4
+ readonly accessKey: Redacted.Redacted<string>;
5
+ }
6
+ declare const BunnyCredentials_base: Context.ServiceClass<BunnyCredentials, "@bunny.net/api-client-effect-v4-runtime/BunnyCredentials", BunnyCredentialsShape>;
7
+ /**
8
+ * The credential service every generated client reads from context.
9
+ *
10
+ * The key is held as `Redacted` so it never appears through
11
+ * `toString`/`JSON.stringify`/inspection; it is revealed at exactly one
12
+ * place, when the auth header is set on the outgoing request.
13
+ */
14
+ export declare class BunnyCredentials extends BunnyCredentials_base {
15
+ /** A layer holding one key. Accepts a raw string (wrapped immediately) or an existing `Redacted`. */
16
+ static readonly layer: (accessKey: string | Redacted.Redacted<string>) => Layer.Layer<BunnyCredentials>;
17
+ /** A layer reading the key from configuration (the environment by default); `BUNNY_API_KEY` unless another name is given. */
18
+ static readonly fromEnv: (name?: string) => Layer.Layer<BunnyCredentials, Config.ConfigError>;
19
+ }
20
+ export {};
@@ -0,0 +1,19 @@
1
+ import { Config, Context, Effect, Layer, Redacted } from "effect";
2
+ /**
3
+ * The credential service every generated client reads from context.
4
+ *
5
+ * The key is held as `Redacted` so it never appears through
6
+ * `toString`/`JSON.stringify`/inspection; it is revealed at exactly one
7
+ * place, when the auth header is set on the outgoing request.
8
+ */
9
+ export class BunnyCredentials extends Context.Service()("@bunny.net/api-client-effect-v4-runtime/BunnyCredentials") {
10
+ /** A layer holding one key. Accepts a raw string (wrapped immediately) or an existing `Redacted`. */
11
+ static layer = (accessKey) => Layer.succeed(BunnyCredentials, {
12
+ accessKey: Redacted.isRedacted(accessKey)
13
+ ? accessKey
14
+ : Redacted.make(accessKey),
15
+ });
16
+ /** A layer reading the key from configuration (the environment by default); `BUNNY_API_KEY` unless another name is given. */
17
+ static fromEnv = (name = "BUNNY_API_KEY") => Layer.effect(BunnyCredentials, Config.Redacted(name).pipe(Effect.map((accessKey) => ({ accessKey }))));
18
+ }
19
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/credentials.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAOlE;;;;;;GAMG;AACH,MAAM,OAAO,gBACX,SAAQ,OAAO,CAAC,OAAO,EAA2C,CAChE,0DAA0D,CAC3D;IACD,qGAAqG;IACrG,MAAM,CAAU,KAAK,GAAG,CACtB,SAA6C,EACd,EAAE,CACjC,KAAK,CAAC,OAAO,CAAC,gBAAgB,EAAE;QAC9B,SAAS,EAAE,QAAQ,CAAC,UAAU,CAAC,SAAS,CAAC;YACvC,CAAC,CAAC,SAAS;YACX,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC;KAC7B,CAAC,CAAC;IAEL,6HAA6H;IAC7H,MAAM,CAAU,OAAO,GAAG,CACxB,IAAI,GAAG,eAAe,EAC6B,EAAE,CACrD,KAAK,CAAC,MAAM,CACV,gBAAgB,EAChB,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,CACvE,CAAC","sourcesContent":["import { Config, Context, Effect, Layer, Redacted } from \"effect\";\n\n/** What a Bunny.net client needs to authenticate: the API key (or, for the Edge Storage API, the zone password). */\nexport interface BunnyCredentialsShape {\n readonly accessKey: Redacted.Redacted<string>;\n}\n\n/**\n * The credential service every generated client reads from context.\n *\n * The key is held as `Redacted` so it never appears through\n * `toString`/`JSON.stringify`/inspection; it is revealed at exactly one\n * place, when the auth header is set on the outgoing request.\n */\nexport class BunnyCredentials\n extends Context.Service<BunnyCredentials, BunnyCredentialsShape>()(\n \"@bunny.net/api-client-effect-v4-runtime/BunnyCredentials\",\n ) {\n /** A layer holding one key. Accepts a raw string (wrapped immediately) or an existing `Redacted`. */\n static readonly layer = (\n accessKey: string | Redacted.Redacted<string>,\n ): Layer.Layer<BunnyCredentials> =>\n Layer.succeed(BunnyCredentials, {\n accessKey: Redacted.isRedacted(accessKey)\n ? accessKey\n : Redacted.make(accessKey),\n });\n\n /** A layer reading the key from configuration (the environment by default); `BUNNY_API_KEY` unless another name is given. */\n static readonly fromEnv = (\n name = \"BUNNY_API_KEY\",\n ): Layer.Layer<BunnyCredentials, Config.ConfigError> =>\n Layer.effect(\n BunnyCredentials,\n Config.Redacted(name).pipe(Effect.map((accessKey) => ({ accessKey }))),\n );\n}\n"]}
@@ -0,0 +1,64 @@
1
+ import { type Schema } from "effect";
2
+ import type { HttpClientError } from "effect/http";
3
+ declare const BunnyInputError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
4
+ readonly _tag: "BunnyInputError";
5
+ } & Readonly<A>;
6
+ /** The caller's input did not satisfy the operation's input schema. */
7
+ export declare class BunnyInputError extends BunnyInputError_base<{
8
+ readonly operation: string;
9
+ readonly cause: Schema.SchemaError;
10
+ }> {
11
+ get message(): string;
12
+ }
13
+ declare const BunnyResponseError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
14
+ readonly _tag: "BunnyResponseError";
15
+ } & Readonly<A>;
16
+ /**
17
+ * The API answered with a status the spec documents as an error. `response`
18
+ * is `{ status, value }` with `value` decoded by that status's schema, so
19
+ * the error channel is as typed as the success channel.
20
+ */
21
+ export declare class BunnyResponseError<R = {
22
+ readonly status: number;
23
+ readonly value: unknown;
24
+ }> extends BunnyResponseError_base<{
25
+ readonly operation: string;
26
+ readonly response: R;
27
+ }> {
28
+ get message(): string;
29
+ }
30
+ declare const BunnyDecodeError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
31
+ readonly _tag: "BunnyDecodeError";
32
+ } & Readonly<A>;
33
+ /**
34
+ * The API answered with a documented status but a body the schema rejects.
35
+ * This is the spec-drift signal: the fix belongs in a `patches.ts` next to
36
+ * the surface's barrel, not in the caller.
37
+ */
38
+ export declare class BunnyDecodeError extends BunnyDecodeError_base<{
39
+ readonly operation: string;
40
+ readonly status: number;
41
+ readonly cause: Schema.SchemaError;
42
+ }> {
43
+ get message(): string;
44
+ }
45
+ declare const BunnyUnexpectedStatusError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
46
+ readonly _tag: "BunnyUnexpectedStatusError";
47
+ } & Readonly<A>;
48
+ /** The API answered with a status the spec does not document at all. `body` is the raw payload (JSON-parsed when possible). */
49
+ export declare class BunnyUnexpectedStatusError extends BunnyUnexpectedStatusError_base<{
50
+ readonly operation: string;
51
+ readonly status: number;
52
+ readonly body: unknown;
53
+ }> {
54
+ get message(): string;
55
+ }
56
+ /**
57
+ * Everything an operation can fail with besides its own typed
58
+ * `BunnyResponseError`. `HttpClientError` covers transport failures
59
+ * (connection, timeout, malformed body) and comes straight from Effect's
60
+ * `effect/http`: one class, tagged `"HttpClientError"`, whose `reason`
61
+ * (`TransportError`, `StatusCodeError`, …) says what happened.
62
+ */
63
+ export type BunnyClientError = BunnyInputError | BunnyDecodeError | BunnyUnexpectedStatusError | HttpClientError.HttpClientError;
64
+ export {};
@@ -0,0 +1,35 @@
1
+ import { Data } from "effect";
2
+ /** The caller's input did not satisfy the operation's input schema. */
3
+ export class BunnyInputError extends Data.TaggedError("BunnyInputError") {
4
+ get message() {
5
+ return `${this.operation}: invalid input — ${this.cause.message}`;
6
+ }
7
+ }
8
+ /**
9
+ * The API answered with a status the spec documents as an error. `response`
10
+ * is `{ status, value }` with `value` decoded by that status's schema, so
11
+ * the error channel is as typed as the success channel.
12
+ */
13
+ export class BunnyResponseError extends Data.TaggedError("BunnyResponseError") {
14
+ get message() {
15
+ const status = this.response.status;
16
+ return `${this.operation}: HTTP ${status ?? "?"}`;
17
+ }
18
+ }
19
+ /**
20
+ * The API answered with a documented status but a body the schema rejects.
21
+ * This is the spec-drift signal: the fix belongs in a `patches.ts` next to
22
+ * the surface's barrel, not in the caller.
23
+ */
24
+ export class BunnyDecodeError extends Data.TaggedError("BunnyDecodeError") {
25
+ get message() {
26
+ return `${this.operation}: HTTP ${this.status} body does not match the spec — ${this.cause.message}`;
27
+ }
28
+ }
29
+ /** The API answered with a status the spec does not document at all. `body` is the raw payload (JSON-parsed when possible). */
30
+ export class BunnyUnexpectedStatusError extends Data.TaggedError("BunnyUnexpectedStatusError") {
31
+ get message() {
32
+ return `${this.operation}: unexpected HTTP ${this.status}`;
33
+ }
34
+ }
35
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAe,MAAM,QAAQ,CAAC;AAG3C,uEAAuE;AACvE,MAAM,OAAO,eAAgB,SAAQ,IAAI,CAAC,WAAW,CAAC,iBAAiB,CAGrE;IACA,IAAa,OAAO;QAClB,OAAO,GAAG,IAAI,CAAC,SAAS,qBAAqB,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;IACpE,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,kBAEX,SAAQ,IAAI,CAAC,WAAW,CAAC,oBAAoB,CAG7C;IACA,IAAa,OAAO;QAClB,MAAM,MAAM,GAAI,IAAI,CAAC,QAAgC,CAAC,MAAM,CAAC;QAC7D,OAAO,GAAG,IAAI,CAAC,SAAS,UAAU,MAAM,IAAI,GAAG,EAAE,CAAC;IACpD,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,gBAAiB,SAAQ,IAAI,CAAC,WAAW,CAAC,kBAAkB,CAIvE;IACA,IAAa,OAAO;QAClB,OAAO,GAAG,IAAI,CAAC,SAAS,UAAU,IAAI,CAAC,MAAM,mCAAmC,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;IACvG,CAAC;CACF;AAED,+HAA+H;AAC/H,MAAM,OAAO,0BACX,SAAQ,IAAI,CAAC,WAAW,CAAC,4BAA4B,CAInD;IACF,IAAa,OAAO;QAClB,OAAO,GAAG,IAAI,CAAC,SAAS,qBAAqB,IAAI,CAAC,MAAM,EAAE,CAAC;IAC7D,CAAC;CACF","sourcesContent":["import { Data, type Schema } from \"effect\";\nimport type { HttpClientError } from \"effect/http\";\n\n/** The caller's input did not satisfy the operation's input schema. */\nexport class BunnyInputError extends Data.TaggedError(\"BunnyInputError\")<{\n readonly operation: string;\n readonly cause: Schema.SchemaError;\n}> {\n override get message(): string {\n return `${this.operation}: invalid input — ${this.cause.message}`;\n }\n}\n\n/**\n * The API answered with a status the spec documents as an error. `response`\n * is `{ status, value }` with `value` decoded by that status's schema, so\n * the error channel is as typed as the success channel.\n */\nexport class BunnyResponseError<\n R = { readonly status: number; readonly value: unknown },\n> extends Data.TaggedError(\"BunnyResponseError\")<{\n readonly operation: string;\n readonly response: R;\n}> {\n override get message(): string {\n const status = (this.response as { status?: number }).status;\n return `${this.operation}: HTTP ${status ?? \"?\"}`;\n }\n}\n\n/**\n * The API answered with a documented status but a body the schema rejects.\n * This is the spec-drift signal: the fix belongs in a `patches.ts` next to\n * the surface's barrel, not in the caller.\n */\nexport class BunnyDecodeError extends Data.TaggedError(\"BunnyDecodeError\")<{\n readonly operation: string;\n readonly status: number;\n readonly cause: Schema.SchemaError;\n}> {\n override get message(): string {\n return `${this.operation}: HTTP ${this.status} body does not match the spec — ${this.cause.message}`;\n }\n}\n\n/** The API answered with a status the spec does not document at all. `body` is the raw payload (JSON-parsed when possible). */\nexport class BunnyUnexpectedStatusError\n extends Data.TaggedError(\"BunnyUnexpectedStatusError\")<{\n readonly operation: string;\n readonly status: number;\n readonly body: unknown;\n }> {\n override get message(): string {\n return `${this.operation}: unexpected HTTP ${this.status}`;\n }\n}\n\n/**\n * Everything an operation can fail with besides its own typed\n * `BunnyResponseError`. `HttpClientError` covers transport failures\n * (connection, timeout, malformed body) and comes straight from Effect's\n * `effect/http`: one class, tagged `\"HttpClientError\"`, whose `reason`\n * (`TransportError`, `StatusCodeError`, …) says what happened.\n */\nexport type BunnyClientError =\n | BunnyInputError\n | BunnyDecodeError\n | BunnyUnexpectedStatusError\n | HttpClientError.HttpClientError;\n"]}
@@ -0,0 +1,99 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { type HttpClient } from "effect/http";
3
+ import type { BunnyCredentialsShape } from "./credentials.js";
4
+ import { type BunnyClientError, BunnyResponseError } from "./errors.js";
5
+ /** How a response body is read before (or instead of) schema decoding. `stream` is read fully, like `binary`. */
6
+ export type ResponseKind = "json" | "text" | "empty" | "binary" | "stream";
7
+ /**
8
+ * Any schema with no decoding and no encoding services: `Codec` is covariant
9
+ * in its type parameters, so every concrete schema is assignable here, and
10
+ * the `never` services pin every operation to `R = never`, whatever schema the
11
+ * generator emits (`Schema.Top` would widen the services to `unknown`).
12
+ */
13
+ export type ServiceFreeCodec = Schema.Codec<unknown, unknown, never, never>;
14
+ export interface ResponseEntry {
15
+ readonly schema: ServiceFreeCodec;
16
+ readonly kind: ResponseKind;
17
+ }
18
+ /** Status code (or `"default"`) → how to read + decode that response. */
19
+ export type ResponseSpec = Record<string, ResponseEntry>;
20
+ export type ResponseValue<T extends ResponseEntry> = T["kind"] extends "stream" | "binary" ? ArrayBuffer : T["schema"]["Type"];
21
+ /** The `{ status, value }` union an operation resolves (or fails) with, one member per documented status. */
22
+ export type ResponseUnion<T extends ResponseSpec> = {
23
+ [K in keyof T]: K extends "default" ? {
24
+ readonly status: number;
25
+ readonly value: ResponseValue<T[K]>;
26
+ } : K extends `${infer N extends number}` ? {
27
+ readonly status: N;
28
+ readonly value: ResponseValue<T[K]>;
29
+ } : K extends number ? {
30
+ readonly status: K;
31
+ readonly value: ResponseValue<T[K]>;
32
+ } : {
33
+ readonly status: number;
34
+ readonly value: ResponseValue<T[K]>;
35
+ };
36
+ }[keyof T];
37
+ /** The typed failure for an operation's documented error statuses (`never` when the spec documents none). */
38
+ export type OperationFailure<E extends ResponseSpec | undefined> = E extends ResponseSpec ? BunnyResponseError<ResponseUnion<E>> : never;
39
+ export type SecurityScheme = {
40
+ readonly type: "http";
41
+ readonly scheme: "bearer" | "basic";
42
+ readonly description?: string;
43
+ } | {
44
+ readonly type: "apiKey";
45
+ readonly in: "header" | "query" | "cookie";
46
+ readonly name: string;
47
+ readonly description?: string;
48
+ } | {
49
+ readonly type: "oauth2";
50
+ readonly flows: unknown;
51
+ readonly description?: string;
52
+ } | {
53
+ readonly type: "openIdConnect";
54
+ readonly openIdConnectUrl: string;
55
+ readonly description?: string;
56
+ };
57
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
58
+ export interface OperationDescriptor<S extends ResponseSpec, E extends ResponseSpec | undefined = undefined> {
59
+ /** Operation id, used in error messages and the tracing span name. */
60
+ readonly name: string;
61
+ readonly method: HttpMethod;
62
+ /** Path template with `{param}` placeholders. */
63
+ readonly path: string;
64
+ /** The spec's server URL for this operation; `ClientOptions.baseUrl` overrides it. */
65
+ readonly baseUrl?: string;
66
+ /** How the credential is sent. Omit for unauthenticated operations. */
67
+ readonly security?: SecurityScheme;
68
+ /** `{ path?, query?, headers?, body? }` schema; omit when the operation takes no input. Its encoded side is checked against `DecodedInput`. */
69
+ readonly input?: Schema.Codec<unknown, DecodedInput, never, never>;
70
+ /** Request body content type; defaults to JSON. */
71
+ readonly contentType?: string;
72
+ readonly success: S;
73
+ readonly error?: E;
74
+ }
75
+ /** Per-client options every generated `layer(options)` accepts. */
76
+ export interface ClientOptions {
77
+ /** Overrides the spec's server URL — e.g. to point a client at a staging host. */
78
+ readonly baseUrl?: string;
79
+ }
80
+ /** What a generated service captures once at layer construction. */
81
+ export interface ClientContext {
82
+ readonly http: HttpClient.HttpClient;
83
+ readonly credentials: BunnyCredentialsShape;
84
+ readonly options: ClientOptions;
85
+ }
86
+ /** The encoded form of an operation's input: what the generated input schema must encode to. */
87
+ export type DecodedInput = {
88
+ readonly path?: Record<string, unknown>;
89
+ readonly query?: Record<string, unknown>;
90
+ readonly headers?: Record<string, unknown>;
91
+ readonly body?: unknown;
92
+ };
93
+ /**
94
+ * Run one operation: decode the input, build the request, send it through
95
+ * the caller's `HttpClient`, and decode the response by status. Resolves
96
+ * with `{ status, value }` for documented success statuses, fails with a
97
+ * typed `BunnyResponseError` for documented error statuses.
98
+ */
99
+ export declare const executeOperation: <S extends ResponseSpec, E extends ResponseSpec | undefined>(ctx: ClientContext, op: OperationDescriptor<S, E>, input: unknown) => Effect.Effect<ResponseUnion<S>, BunnyClientError | OperationFailure<E>, never>;
@@ -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[];
@@ -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":"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","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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.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
  }